Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf204f90f3 |
@@ -59,7 +59,7 @@ PROVISIONER_IMAGE ?= mesh-provision-postgres:$(VERSION)
|
|||||||
PROVISIONER_DEV_TAG ?= mesh-provision-postgres:development
|
PROVISIONER_DEV_TAG ?= mesh-provision-postgres:development
|
||||||
|
|
||||||
provisioner-image:
|
provisioner-image:
|
||||||
docker build --build-arg GO_BASE=$(GO_BASE) -f examples/postgres-provisioner/Dockerfile \
|
docker build -f examples/postgres-provisioner/Dockerfile \
|
||||||
-t $(PROVISIONER_IMAGE) -t $(PROVISIONER_DEV_TAG) .
|
-t $(PROVISIONER_IMAGE) -t $(PROVISIONER_DEV_TAG) .
|
||||||
@echo
|
@echo
|
||||||
@docker image inspect $(PROVISIONER_IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes'
|
@docker image inspect $(PROVISIONER_IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes'
|
||||||
@@ -70,7 +70,7 @@ OBJECTSTORE_IMAGE ?= mesh-provision-objectstore:$(VERSION)
|
|||||||
OBJECTSTORE_DEV_TAG ?= mesh-provision-objectstore:development
|
OBJECTSTORE_DEV_TAG ?= mesh-provision-objectstore:development
|
||||||
|
|
||||||
objectstore-image:
|
objectstore-image:
|
||||||
docker build --build-arg GO_BASE=$(GO_BASE) -f examples/objectstore-provisioner/Dockerfile \
|
docker build -f examples/objectstore-provisioner/Dockerfile \
|
||||||
-t $(OBJECTSTORE_IMAGE) -t $(OBJECTSTORE_DEV_TAG) .
|
-t $(OBJECTSTORE_IMAGE) -t $(OBJECTSTORE_DEV_TAG) .
|
||||||
@echo
|
@echo
|
||||||
@docker image inspect $(OBJECTSTORE_IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes'
|
@docker image inspect $(OBJECTSTORE_IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes'
|
||||||
@@ -81,7 +81,7 @@ REDIS_PROVISIONER_IMAGE ?= mesh-provision-redis:$(VERSION)
|
|||||||
REDIS_PROVISIONER_DEV_TAG ?= mesh-provision-redis:development
|
REDIS_PROVISIONER_DEV_TAG ?= mesh-provision-redis:development
|
||||||
|
|
||||||
redis-provisioner-image:
|
redis-provisioner-image:
|
||||||
docker build --build-arg GO_BASE=$(GO_BASE) -f examples/redis-provisioner/Dockerfile \
|
docker build -f examples/redis-provisioner/Dockerfile \
|
||||||
-t $(REDIS_PROVISIONER_IMAGE) -t $(REDIS_PROVISIONER_DEV_TAG) .
|
-t $(REDIS_PROVISIONER_IMAGE) -t $(REDIS_PROVISIONER_DEV_TAG) .
|
||||||
@echo
|
@echo
|
||||||
@docker image inspect $(REDIS_PROVISIONER_IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes'
|
@docker image inspect $(REDIS_PROVISIONER_IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes'
|
||||||
@@ -91,7 +91,7 @@ PROXY_IMAGE ?= mesh-route-proxy:$(VERSION)
|
|||||||
PROXY_DEV_TAG ?= mesh-route-proxy:development
|
PROXY_DEV_TAG ?= mesh-route-proxy:development
|
||||||
|
|
||||||
proxy-image:
|
proxy-image:
|
||||||
docker build --build-arg GO_BASE=$(GO_BASE) -f examples/route-proxy/Dockerfile -t $(PROXY_IMAGE) -t $(PROXY_DEV_TAG) .
|
docker build -f examples/route-proxy/Dockerfile -t $(PROXY_IMAGE) -t $(PROXY_DEV_TAG) .
|
||||||
@echo
|
@echo
|
||||||
@docker image inspect $(PROXY_IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes'
|
@docker image inspect $(PROXY_IMAGE) --format 'built {{.RepoTags}} {{.Size}} bytes'
|
||||||
|
|
||||||
|
|||||||
@@ -95,22 +95,11 @@ func reportsReaching(t *testing.T, open *stores, reachable []link.Reach, held ..
|
|||||||
// filter is not sent to one that has not. The anchor reports one, as a real host does; this
|
// filter is not sent to one that has not. The anchor reports one, as a real host does; this
|
||||||
// fixture lacked it from 2026-09-28 and nothing ran the test (issue 177).
|
// fixture lacked it from 2026-09-28 and nothing ran the test (issue 177).
|
||||||
Outward: []string{"eth0"},
|
Outward: []string{"eth0"},
|
||||||
// And what filters it (ADR 0168): its front end, the runtime's own, and a chain a
|
|
||||||
// predecessor left in the runtime's user chain.
|
|
||||||
Filters: anchorFilters,
|
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// anchorFilters is what the adopted anchor says filters it: ufw's chains, the runtime's, and a
|
|
||||||
// predecessor's chain the mesh did not write.
|
|
||||||
var anchorFilters = []link.Filter{
|
|
||||||
{Where: "table ip filter, chain ufw-reject-input", Owner: "found-firewall", Refuses: "reject"},
|
|
||||||
{Where: "table ip filter, chain DOCKER", Owner: "runtime", Refuses: `iifname != "docker0" oifname "docker0" drop`},
|
|
||||||
{Where: "table ip filter, chain DOCKER-USER", Owner: "other", Refuses: `iifname "eth0" tcp dport 6000 drop`},
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
heldContainer = link.Held{ID: "hello-web.server", Module: "hello-web", Kind: "container",
|
heldContainer = link.Held{ID: "hello-web.server", Module: "hello-web", Kind: "container",
|
||||||
Target: "hello-web", Since: time.Now()}
|
Target: "hello-web", Since: time.Now()}
|
||||||
@@ -120,10 +109,10 @@ var (
|
|||||||
|
|
||||||
func TestTakingAModuleNotOnTheNodeIsRefused(t *testing.T) {
|
func TestTakingAModuleNotOnTheNodeIsRefused(t *testing.T) {
|
||||||
open, _ := anAdoptedAnchor(t)
|
open, _ := anAdoptedAnchor(t)
|
||||||
if _, err := take(t.Context(), open, "anchor", "nftables", takeOptions{Yes: true}); !errors.Is(err, inventory.ErrNotAssigned) {
|
if _, err := take(t.Context(), open, "anchor", "nftables"); !errors.Is(err, inventory.ErrNotAssigned) {
|
||||||
t.Fatalf("taking an unassigned module gave %v", err)
|
t.Fatalf("taking an unassigned module gave %v", err)
|
||||||
}
|
}
|
||||||
if _, err := take(t.Context(), open, "laptop", "network", takeOptions{Yes: true}); !errors.Is(err, inventory.ErrNotAdopted) {
|
if _, err := take(t.Context(), open, "laptop", "network"); !errors.Is(err, inventory.ErrNotAdopted) {
|
||||||
t.Fatalf("taking on a converged node gave %v", err)
|
t.Fatalf("taking on a converged node gave %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,24 +143,7 @@ func TestTheFlipIsRefusedWhileAFoundContainerIsHeld(t *testing.T) {
|
|||||||
func TestTakingNamesWhatItReplaces(t *testing.T) {
|
func TestTakingNamesWhatItReplaces(t *testing.T) {
|
||||||
open, _ := anAdoptedAnchor(t)
|
open, _ := anAdoptedAnchor(t)
|
||||||
reportsHolding(t, open, heldContainer, heldFile)
|
reportsHolding(t, open, heldContainer, heldFile)
|
||||||
ctx := t.Context()
|
said, err := take(t.Context(), open, "anchor", "hello-web")
|
||||||
// The machine holds something for the module, so the take acts on the preview the operator
|
|
||||||
// saw and names its digest (novox/hq ADR 0163).
|
|
||||||
preview, err := take(ctx, open, "anchor", "hello-web", takeOptions{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
saw := takeDigestIn(t, preview)
|
|
||||||
if !strings.Contains(preview, "nothing taken; `take anchor hello-web --yes "+saw+"`") {
|
|
||||||
t.Fatalf("the preview does not say how to act on it:\n%s", preview)
|
|
||||||
}
|
|
||||||
if taken, _ := open.inventory.Taken(ctx, "anchor"); len(taken) != 0 {
|
|
||||||
t.Fatal("the preview took something")
|
|
||||||
}
|
|
||||||
if _, err := take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true}); err == nil || !strings.Contains(err.Error(), "name its digest") {
|
|
||||||
t.Fatalf("--yes without the digest was not refused: %v", err)
|
|
||||||
}
|
|
||||||
said, err := take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true, Digest: saw})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -181,71 +153,10 @@ func TestTakingNamesWhatItReplaces(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// takeDigestIn is the digest a take's preview printed.
|
|
||||||
func takeDigestIn(t *testing.T, preview string) string {
|
|
||||||
t.Helper()
|
|
||||||
for _, line := range strings.Split(preview, "\n") {
|
|
||||||
if fields := strings.Fields(line); len(fields) == 2 && fields[0] == "preview" {
|
|
||||||
return fields[1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
t.Fatalf("the preview printed no digest:\n%s", preview)
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// A take acts on the preview the operator saw, and on an account of the machine that is still the
|
|
||||||
// machine: a changed preview and a stale account refuse (novox/hq ADR 0163, rule 1).
|
|
||||||
func TestATakeIsRefusedOnAChangedPreviewOrAStaleAccount(t *testing.T) {
|
|
||||||
open, _ := anAdoptedAnchor(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
reportsHolding(t, open, heldContainer, heldFile)
|
|
||||||
preview, err := take(ctx, open, "anchor", "hello-web", takeOptions{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
saw := takeDigestIn(t, preview)
|
|
||||||
|
|
||||||
// The machine reports again, and what it holds has changed: the found container now carries
|
|
||||||
// facts the preview never showed.
|
|
||||||
changed := heldContainer
|
|
||||||
changed.Facts = map[string]any{"image": "hello:2", "declared_image": "registry.example/hello"}
|
|
||||||
reportsHolding(t, open, changed, heldFile)
|
|
||||||
_, err = take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true, Digest: saw})
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "has changed since preview "+saw) {
|
|
||||||
t.Fatalf("a changed preview was acted on: %v", err)
|
|
||||||
}
|
|
||||||
if taken, _ := open.inventory.Taken(ctx, "anchor"); len(taken) != 0 {
|
|
||||||
t.Fatal("a refused take took something")
|
|
||||||
}
|
|
||||||
|
|
||||||
// And an account older than the flip allows.
|
|
||||||
preview, err = take(ctx, open, "anchor", "hello-web", takeOptions{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
saw = takeDigestIn(t, preview)
|
|
||||||
saved := reportFreshFor
|
|
||||||
reportFreshFor = -time.Second
|
|
||||||
defer func() { reportFreshFor = saved }()
|
|
||||||
_, err = take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true, Digest: saw})
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "a take acts only on an account newer than") {
|
|
||||||
t.Fatalf("a stale account was acted on: %v", err)
|
|
||||||
}
|
|
||||||
reportFreshFor = saved
|
|
||||||
if _, err := take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true, Digest: saw}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
// A module the machine holds nothing for has nothing to compare: --yes alone suffices.
|
|
||||||
if _, err := take(ctx, open, "anchor", "notes", takeOptions{Yes: true}); err == nil {
|
|
||||||
// notes holds a file, so this one needs the digest too.
|
|
||||||
t.Fatal("notes holds a found file and was taken without a digest")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConvergingPreviewsThenChangesAndAdoptingKeepsWhatWasTaken(t *testing.T) {
|
func TestConvergingPreviewsThenChangesAndAdoptingKeepsWhatWasTaken(t *testing.T) {
|
||||||
open, sent := anAdoptedAnchor(t)
|
open, sent := anAdoptedAnchor(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
if _, err := take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true}); err != nil {
|
if _, err := take(ctx, open, "anchor", "hello-web"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
reportsHolding(t, open, heldFile)
|
reportsHolding(t, open, heldFile)
|
||||||
@@ -275,20 +186,6 @@ func TestConvergingPreviewsThenChangesAndAdoptingKeepsWhatWasTaken(t *testing.T)
|
|||||||
if strings.Contains(preview, "15672") {
|
if strings.Contains(preview, "15672") {
|
||||||
t.Errorf("a loopback listener is in the preview:\n%s", preview)
|
t.Errorf("a loopback listener is in the preview:\n%s", preview)
|
||||||
}
|
}
|
||||||
// What filters the machine now, and the fate of each (novox/hq ADR 0168): the predecessor's
|
|
||||||
// chain is named as not the mesh's and left, so the reader knows before the flip.
|
|
||||||
for _, want := range []string{
|
|
||||||
"table ip filter, chain DOCKER-USER",
|
|
||||||
"NOT THE MESH'S; left in force",
|
|
||||||
`iifname "eth0" tcp dport 6000 drop`,
|
|
||||||
"table ip filter, chain ufw-reject-input",
|
|
||||||
"the found firewall's; retired with it",
|
|
||||||
"the container runtime's own; left",
|
|
||||||
} {
|
|
||||||
if !strings.Contains(preview, want) {
|
|
||||||
t.Errorf("the preview does not say %q:\n%s", want, preview)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, line := range strings.Split(preview, "\n") {
|
for _, line := range strings.Split(preview, "\n") {
|
||||||
if strings.Contains(line, "5000") && !strings.Contains(line, "WILL CLOSE") {
|
if strings.Contains(line, "5000") && !strings.Contains(line, "WILL CLOSE") {
|
||||||
t.Errorf("an undeclared published port is not said to close: %s", line)
|
t.Errorf("an undeclared published port is not said to close: %s", line)
|
||||||
@@ -433,7 +330,7 @@ func digestIn(t *testing.T, preview string) string {
|
|||||||
func TestTheFlipActsOnlyOnThePreviewTheOperatorSaw(t *testing.T) {
|
func TestTheFlipActsOnlyOnThePreviewTheOperatorSaw(t *testing.T) {
|
||||||
open, sent := anAdoptedAnchor(t)
|
open, sent := anAdoptedAnchor(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
if _, err := take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true}); err != nil {
|
if _, err := take(ctx, open, "anchor", "hello-web"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
reportsHolding(t, open, heldFile)
|
reportsHolding(t, open, heldFile)
|
||||||
@@ -499,7 +396,7 @@ func TestTheFlipActsOnlyOnThePreviewTheOperatorSaw(t *testing.T) {
|
|||||||
func TestTheFlipHoldsTheNodeWhileItSends(t *testing.T) {
|
func TestTheFlipHoldsTheNodeWhileItSends(t *testing.T) {
|
||||||
open, _ := anAdoptedAnchor(t)
|
open, _ := anAdoptedAnchor(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
if _, err := take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true}); err != nil {
|
if _, err := take(ctx, open, "anchor", "hello-web"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
reportsHolding(t, open, heldFile)
|
reportsHolding(t, open, heldFile)
|
||||||
@@ -544,7 +441,7 @@ func TestTheFlipHoldsTheNodeWhileItSends(t *testing.T) {
|
|||||||
func TestThePreviewNamesEveryHeldKind(t *testing.T) {
|
func TestThePreviewNamesEveryHeldKind(t *testing.T) {
|
||||||
open, _ := anAdoptedAnchor(t)
|
open, _ := anAdoptedAnchor(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
if _, err := take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true}); err != nil {
|
if _, err := take(ctx, open, "anchor", "hello-web"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
since := time.Now()
|
since := time.Now()
|
||||||
@@ -587,7 +484,7 @@ func TestThePreviewNamesEveryHeldKind(t *testing.T) {
|
|||||||
func TestTheFlipIsRefusedOnAnAccountNamingNothingReachable(t *testing.T) {
|
func TestTheFlipIsRefusedOnAnAccountNamingNothingReachable(t *testing.T) {
|
||||||
open, sent := anAdoptedAnchor(t)
|
open, sent := anAdoptedAnchor(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
if _, err := take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true}); err != nil {
|
if _, err := take(ctx, open, "anchor", "hello-web"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// Only a loopback listener: nothing off the machine, which is the same silence.
|
// Only a loopback listener: nothing off the machine, which is the same silence.
|
||||||
@@ -615,7 +512,7 @@ func TestTheFlipIsRefusedOnAnAccountNamingNothingReachable(t *testing.T) {
|
|||||||
func TestAssigningWaitsForWhateverIsConvergingTheNode(t *testing.T) {
|
func TestAssigningWaitsForWhateverIsConvergingTheNode(t *testing.T) {
|
||||||
open, _ := anAdoptedAnchor(t)
|
open, _ := anAdoptedAnchor(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
if _, err := take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true}); err != nil {
|
if _, err := take(ctx, open, "anchor", "hello-web"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
reportsHolding(t, open, heldFile)
|
reportsHolding(t, open, heldFile)
|
||||||
|
|||||||
+14
-479
@@ -24,15 +24,6 @@ import (
|
|||||||
func showMode(ctx context.Context, inv *inventory.Inventory, node inventory.Node) error {
|
func showMode(ctx context.Context, inv *inventory.Inventory, node inventory.Node) error {
|
||||||
if !node.Adopted {
|
if !node.Adopted {
|
||||||
fmt.Printf(" mode converged\n")
|
fmt.Printf(" mode converged\n")
|
||||||
// A converged machine holds nothing, and can still run what nobody asked for
|
|
||||||
// (novox/hq ADR 0163): what it reports as strays is said whatever its mode.
|
|
||||||
if said, err := inv.AdoptionOf(ctx, node.Name); err == nil && len(said.Strays) > 0 {
|
|
||||||
showStrays(said.Strays)
|
|
||||||
}
|
|
||||||
// And what filters it, truthfully (novox/hq ADR 0168): the mesh alone, or not.
|
|
||||||
if filtering, err := inv.FilteringOf(ctx, node.Name); err == nil {
|
|
||||||
showFiltering(filtering, false)
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
fmt.Printf(" mode adopted since %s\n",
|
fmt.Printf(" mode adopted since %s\n",
|
||||||
@@ -71,81 +62,11 @@ func showMode(ctx context.Context, inv *inventory.Inventory, node inventory.Node
|
|||||||
if h.Kept != "" {
|
if h.Kept != "" {
|
||||||
fmt.Printf(" %-17s original kept at %s\n", "", h.Kept)
|
fmt.Printf(" %-17s original kept at %s\n", "", h.Kept)
|
||||||
}
|
}
|
||||||
for _, f := range comparisonLines(h) {
|
|
||||||
fmt.Printf(" %-17s %s\n", "", f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
showStrays(said.Strays)
|
|
||||||
if filtering, err := inv.FilteringOf(ctx, node.Name); err == nil {
|
|
||||||
showFiltering(filtering, true)
|
|
||||||
}
|
}
|
||||||
fmt.Printf(" as of %s\n", said.At.Local().Format(time.DateTime))
|
fmt.Printf(" as of %s\n", said.At.Local().Format(time.DateTime))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// showFiltering says what filters a machine, with owners (novox/hq ADR 0168), and for a converged
|
|
||||||
// machine the state of the firewall it was found with. A machine that has not said is not said to
|
|
||||||
// be filtered by anything.
|
|
||||||
func showFiltering(f inventory.Filtering, adopted bool) {
|
|
||||||
if len(f.Filters) == 0 && f.FoundFirewall == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if fw := f.FoundFirewall; fw != nil && !adopted {
|
|
||||||
switch {
|
|
||||||
case fw.Active:
|
|
||||||
fmt.Printf(" found firewall %s is ACTIVE on this converged machine; the next apply retires it again\n", fw.Kind)
|
|
||||||
case fw.RetiredBy == "removed":
|
|
||||||
fmt.Printf(" found firewall %s, removed: the mesh's filter is what filters this machine (novox/hq ADR 0175)\n", fw.Kind)
|
|
||||||
case fw.RetiredBy == inventory.FilterMesh || fw.RetiredBy == "mesh":
|
|
||||||
fmt.Printf(" found firewall %s, retired by the mesh; its configuration stays on disk\n", fw.Kind)
|
|
||||||
case fw.RetiredBy != "":
|
|
||||||
fmt.Printf(" found firewall %s, found inactive — not by the mesh\n", fw.Kind)
|
|
||||||
default:
|
|
||||||
fmt.Printf(" found firewall %s, inactive\n", fw.Kind)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(f.Filters) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if f.Alone() {
|
|
||||||
fmt.Printf(" filtered by the mesh alone (%s)\n", filterSummary(f.Filters))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Printf(" filtered by NOT the mesh alone: %d rule set(s) the mesh did not write refuse traffic here\n", len(f.Others()))
|
|
||||||
for _, x := range f.Filters {
|
|
||||||
if x.Owner == inventory.FilterOther || x.Owner == inventory.FilterFoundFirewall {
|
|
||||||
fmt.Printf(" %-17s %s — %s: %s\n", "", x.Where, x.Owner, x.Refuses)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Printf(" %-17s and its own: %s\n", "", filterSummary(f.Filters))
|
|
||||||
}
|
|
||||||
|
|
||||||
// filterSummary counts a machine's filters by owner: "mesh 2, runtime 3, ban 1".
|
|
||||||
func filterSummary(filters []inventory.Filter) string {
|
|
||||||
counts := map[string]int{}
|
|
||||||
for _, x := range filters {
|
|
||||||
counts[x.Owner]++
|
|
||||||
}
|
|
||||||
var parts []string
|
|
||||||
for _, owner := range []string{inventory.FilterMesh, inventory.FilterRuntime, inventory.FilterBan, inventory.FilterFoundFirewall, inventory.FilterOther} {
|
|
||||||
if n := counts[owner]; n > 0 {
|
|
||||||
parts = append(parts, fmt.Sprintf("%s %d", owner, n))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return strings.Join(parts, ", ")
|
|
||||||
}
|
|
||||||
|
|
||||||
// showStrays says what a machine runs that the mesh neither wrote nor holds (ADR 0163).
|
|
||||||
func showStrays(strays []inventory.Stray) {
|
|
||||||
if len(strays) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Printf(" strays %d container(s) the mesh neither wrote nor holds:\n", len(strays))
|
|
||||||
for _, s := range strays {
|
|
||||||
fmt.Printf(" %-17s %s (%s)\n", "", s.Name, s.Detail)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// showTunnel is the node show lines about the tunnel an adopted node found and carried (novox/hq
|
// showTunnel is the node show lines about the tunnel an adopted node found and carried (novox/hq
|
||||||
// ADR 0105): what it presented at enrolment, and what it last said about taking it over.
|
// ADR 0105): what it presented at enrolment, and what it last said about taking it over.
|
||||||
func showTunnel(ctx context.Context, inv *inventory.Inventory, name string) error {
|
func showTunnel(ctx context.Context, inv *inventory.Inventory, name string) error {
|
||||||
@@ -215,28 +136,7 @@ const DefaultFilter = "nftables"
|
|||||||
// take is a module's cutover on an adopted node: the operator's act, done when that module's data
|
// take is a module's cutover on an adopted node: the operator's act, done when that module's data
|
||||||
// has moved. From the next push its resources converge there like any other, replacing what the
|
// has moved. From the next push its resources converge there like any other, replacing what the
|
||||||
// node found and holds for it.
|
// node found and holds for it.
|
||||||
//
|
func take(ctx context.Context, open *stores, node, module string) (string, error) {
|
||||||
// **Previewed, and the preview is a comparison** (novox/hq ADR 0163): for every held thing the
|
|
||||||
// module would replace, what runs beside what the module declares, and the difference; the
|
|
||||||
// module's secrets on the machine and where each came from; its settings on the machine. Without
|
|
||||||
// --yes the comparison is printed and nothing changes. `--yes <digest>` cuts over exactly what was
|
|
||||||
// previewed, the way the flip is confirmed: the preview ends with a digest of what it said, and a
|
|
||||||
// take naming an older one, or acting on an account of the machine older than the flip allows, is
|
|
||||||
// refused. A module the machine holds nothing for has nothing to compare, and `--yes` suffices.
|
|
||||||
// takeOptions is what a take was told about the differences it may pass (novox/hq ADR 0163).
|
|
||||||
type takeOptions struct {
|
|
||||||
Yes bool
|
|
||||||
// Digest is the preview's, named with --yes; required whenever the machine holds something
|
|
||||||
// for the module.
|
|
||||||
Digest string
|
|
||||||
Downgrade bool
|
|
||||||
Replace map[string]bool
|
|
||||||
// Mint names the secrets the service shall take a new value for, although the mesh minted
|
|
||||||
// one and the service already has its own (rule 2).
|
|
||||||
Mint map[string]bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func take(ctx context.Context, open *stores, node, module string, opts takeOptions) (string, error) {
|
|
||||||
inv := open.inventory
|
inv := open.inventory
|
||||||
assigned, err := inv.Assigned(ctx, node)
|
assigned, err := inv.Assigned(ctx, node)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -250,335 +150,25 @@ func take(ctx context.Context, open *stores, node, module string, opts takeOptio
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The comparison first (novox/hq ADR 0163): every held thing the module would replace, beside
|
|
||||||
// what the module declares, and the differences that refuse unless named.
|
|
||||||
c, err := comparisonFor(ctx, open, node, module)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
preview, refusals, saw := comparisonOf(module, c, opts)
|
|
||||||
if len(refusals) > 0 {
|
|
||||||
return "", fmt.Errorf("taking %s on %s is refused:\n %s\n%s", module, node,
|
|
||||||
strings.Join(refusals, "\n "), preview)
|
|
||||||
}
|
|
||||||
holds := len(heldOf(c.reported, module)) > 0
|
|
||||||
if holds {
|
|
||||||
preview += "\n preview " + saw
|
|
||||||
}
|
|
||||||
if !opts.Yes {
|
|
||||||
if !holds {
|
|
||||||
return preview + fmt.Sprintf("\nnothing taken; `take %s %s --yes` declares it as the mesh's own", node, module), nil
|
|
||||||
}
|
|
||||||
return preview + fmt.Sprintf("\nnothing taken; `take %s %s --yes %s` cuts it over as previewed", node, module, saw), nil
|
|
||||||
}
|
|
||||||
if holds {
|
|
||||||
// The take acts on the preview the operator saw, and on an account of the machine that
|
|
||||||
// is still the machine: the same two refusals the flip makes.
|
|
||||||
if age := time.Since(c.reported.At); age > reportFreshFor {
|
|
||||||
return preview, fmt.Errorf("%s last said what it holds %s ago, and a take acts only on "+
|
|
||||||
"an account newer than %s: run `push %s --wait 2m`, then preview again",
|
|
||||||
node, age.Round(time.Second), reportFreshFor, node)
|
|
||||||
}
|
|
||||||
if opts.Digest == "" {
|
|
||||||
return preview, fmt.Errorf("taking %s on %s acts on the preview you saw: name its digest, "+
|
|
||||||
"`take %s %s --yes %s`, once you have read it", module, node, node, module, saw)
|
|
||||||
}
|
|
||||||
if opts.Digest != saw {
|
|
||||||
return preview, fmt.Errorf("what taking %s on %s would replace has changed since preview %s "+
|
|
||||||
"(it is now %s): read the preview above, and run `take %s %s --yes %s` if it is "+
|
|
||||||
"what you want", module, node, opts.Digest, saw, node, module, saw)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := inv.Take(ctx, node, module); err != nil {
|
if err := inv.Take(ctx, node, module); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
said := fmt.Sprintf("%s is taken on %s", module, node)
|
said := fmt.Sprintf("%s is taken on %s", module, node)
|
||||||
if holds {
|
reported, err := inv.AdoptionOf(ctx, node)
|
||||||
said += "; the next push replaces what the node found and holds for it:\n" + preview
|
|
||||||
}
|
|
||||||
return said + fmt.Sprintf("\n run `push %s` to cut it over", node), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// comparison is everything a take puts beside what the module declares: the machine's account of
|
|
||||||
// what it holds and what is reachable on it, the module's secrets on the machine, its settings
|
|
||||||
// there, and which found networks a setting keeps for each of its containers (by held id).
|
|
||||||
type comparison struct {
|
|
||||||
reported inventory.Adoption
|
|
||||||
secrets []inventory.SecretState
|
|
||||||
layers []catalogue.Layer
|
|
||||||
keeps map[string][]string
|
|
||||||
// settingsRefused is why the module's settings cannot compose with its definition, when
|
|
||||||
// they cannot — the module would be left out of the declaration (rule 6).
|
|
||||||
settingsRefused string
|
|
||||||
}
|
|
||||||
|
|
||||||
func comparisonFor(ctx context.Context, open *stores, node, module string) (comparison, error) {
|
|
||||||
inv := open.inventory
|
|
||||||
var c comparison
|
|
||||||
var err error
|
|
||||||
if c.reported, err = inv.AdoptionOf(ctx, node); err != nil {
|
|
||||||
return c, err
|
|
||||||
}
|
|
||||||
if c.secrets, err = inv.SecretsOf(ctx, node, module); err != nil {
|
|
||||||
return c, err
|
|
||||||
}
|
|
||||||
if c.layers, err = inv.SettingsFor(ctx, node, module); err != nil {
|
|
||||||
return c, err
|
|
||||||
}
|
|
||||||
shelf, err := inv.Catalogue(ctx)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c, err
|
return "", err
|
||||||
}
|
}
|
||||||
if m, known := shelf[module]; known && len(c.layers) > 0 {
|
var replaces []string
|
||||||
if err := catalogue.JudgeSettings(m, c.layers, true); err != nil {
|
|
||||||
c.settingsRefused = err.Error()
|
|
||||||
}
|
|
||||||
if kept, err := catalogue.KeptNetworks(m, c.layers, true); err == nil && len(kept) > 0 {
|
|
||||||
c.keeps = map[string][]string{}
|
|
||||||
for id, networks := range kept {
|
|
||||||
c.keeps[module+"."+id] = networks
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return c, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// heldOf is what a node holds for one module.
|
|
||||||
func heldOf(reported inventory.Adoption, module string) []inventory.Held {
|
|
||||||
var out []inventory.Held
|
|
||||||
for _, h := range reported.Held {
|
for _, h := range reported.Held {
|
||||||
if h.Module == module {
|
if h.Module == module {
|
||||||
out = append(out, h)
|
replaces = append(replaces, " "+heldLine(h))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
if len(replaces) > 0 {
|
||||||
|
said += "; the next push replaces what the node found and holds for it:\n" +
|
||||||
|
strings.Join(replaces, "\n")
|
||||||
}
|
}
|
||||||
|
return said + fmt.Sprintf("\n run `push %s` to cut it over", node), nil
|
||||||
// comparisonOf is a take's preview: for every held thing of the module, what runs beside what the
|
|
||||||
// module declares; its secrets and its settings on the machine; and the refusals the differences
|
|
||||||
// earn unless the take named them (novox/hq ADR 0163): an image older than the one running, a
|
|
||||||
// declared file that differs from the found one, a secret the mesh minted for a service whose data
|
|
||||||
// was found. A narrowed port and a shared network are said and not refused. The digest is of what
|
|
||||||
// the preview says, so anything in it changing changes the digest.
|
|
||||||
func comparisonOf(module string, c comparison, opts takeOptions) (preview string, refusals []string, digest string) {
|
|
||||||
var b strings.Builder
|
|
||||||
held := heldOf(c.reported, module)
|
|
||||||
foundData := false
|
|
||||||
for _, h := range held {
|
|
||||||
if h.Kind == "container" || h.Kind == "directory" {
|
|
||||||
foundData = true
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&b, " %s", heldLine(h))
|
|
||||||
if h.Kept != "" {
|
|
||||||
fmt.Fprintf(&b, ", original kept at %s", h.Kept)
|
|
||||||
}
|
|
||||||
b.WriteString("\n")
|
|
||||||
for _, line := range comparisonLinesWith(h, c.keeps[h.ID], c.reported) {
|
|
||||||
fmt.Fprintf(&b, " %s\n", line)
|
|
||||||
}
|
|
||||||
f := factsOf(h)
|
|
||||||
if f.downgrade && !opts.Downgrade {
|
|
||||||
refusals = append(refusals, fmt.Sprintf("%s: the module's image (%s, made %s) is older than the one running (%s, made %s) — "+
|
|
||||||
"a service that migrated its data forward may not start on it; `--downgrade` to take it anyway",
|
|
||||||
h.Target, f.declaredImage, day(f.declaredCreated), f.image, day(f.imageCreated)))
|
|
||||||
}
|
|
||||||
if f.differs && !opts.Replace[h.Target] && !opts.Replace["*"] {
|
|
||||||
refusals = append(refusals, fmt.Sprintf("%s: the module's content differs from the file found; the lines above "+
|
|
||||||
"marked - are lost by taking it; `--replace %s` to replace it anyway, or declare the file partially",
|
|
||||||
h.Target, h.Target))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// The module's secrets on the machine (rule 2 and 3): a service whose data was found already
|
|
||||||
// has a value for each, so one the mesh minted and nobody accepted refuses unless --mint says
|
|
||||||
// the service shall take a new one.
|
|
||||||
for _, sec := range c.secrets {
|
|
||||||
name := sec.Name
|
|
||||||
if sec.Local != "" {
|
|
||||||
name += " (" + sec.Local + ")"
|
|
||||||
}
|
|
||||||
what := "own secret"
|
|
||||||
accept := fmt.Sprintf("`secret accept <node> %s %s`", module, sec.Name)
|
|
||||||
if !sec.Own() {
|
|
||||||
what = "secret from " + sec.Provider
|
|
||||||
accept = fmt.Sprintf("`secret accept <node> %s %s --provider %s`", module, sec.Name, sec.Provider)
|
|
||||||
if sec.Local != "" {
|
|
||||||
accept = strings.TrimSuffix(accept, "`") + " --local " + sec.Local + "`"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case sec.Origin == inventory.OriginAccepted:
|
|
||||||
fmt.Fprintf(&b, " %s %s: accepted from a person, carried in as it is\n", what, name)
|
|
||||||
case opts.Mint[sec.Name]:
|
|
||||||
fmt.Fprintf(&b, " %s %s: minted by the mesh; the service takes the new value, as --mint said\n", what, name)
|
|
||||||
case foundData:
|
|
||||||
fmt.Fprintf(&b, " %s %s: MINTED by the mesh and not accepted — the running service already has one\n", what, name)
|
|
||||||
refusals = append(refusals, fmt.Sprintf("%s: the mesh minted a value and the service whose data was found "+
|
|
||||||
"already uses its own; %s carries the existing value in, or `--mint %s` says the service shall take "+
|
|
||||||
"the new one", name, accept, sec.Name))
|
|
||||||
default:
|
|
||||||
fmt.Fprintf(&b, " %s %s: minted by the mesh\n", what, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// And its settings on this machine, composed against its definition (rule 1, rule 6).
|
|
||||||
for _, layer := range c.layers {
|
|
||||||
keys := make([]string, 0, len(layer.Values))
|
|
||||||
for k := range layer.Values {
|
|
||||||
keys = append(keys, k)
|
|
||||||
}
|
|
||||||
sort.Strings(keys)
|
|
||||||
fmt.Fprintf(&b, " settings from %s: %s\n", layer.From, strings.Join(keys, ", "))
|
|
||||||
}
|
|
||||||
if c.settingsRefused != "" {
|
|
||||||
fmt.Fprintf(&b, " SETTINGS DO NOT COMPOSE with the module's definition, so the push leaves it out: %s\n", c.settingsRefused)
|
|
||||||
}
|
|
||||||
preview = strings.TrimRight(b.String(), "\n")
|
|
||||||
sum := sha256.Sum256([]byte(preview))
|
|
||||||
return preview, refusals, hex.EncodeToString(sum[:])[:12]
|
|
||||||
}
|
|
||||||
|
|
||||||
// facts is a held thing's facts as the preview reads them.
|
|
||||||
type facts struct {
|
|
||||||
image, imageCreated, declaredImage, declaredCreated string
|
|
||||||
downgrade, differs bool
|
|
||||||
networks map[string][]string
|
|
||||||
mounts, ports, declaredPorts, declaredVolumes []string
|
|
||||||
difference []string
|
|
||||||
}
|
|
||||||
|
|
||||||
func factsOf(h inventory.Held) facts {
|
|
||||||
var f facts
|
|
||||||
if h.Facts == nil {
|
|
||||||
return f
|
|
||||||
}
|
|
||||||
str := func(k string) string { s, _ := h.Facts[k].(string); return s }
|
|
||||||
list := func(k string) []string {
|
|
||||||
var out []string
|
|
||||||
if raw, ok := h.Facts[k].([]any); ok {
|
|
||||||
for _, x := range raw {
|
|
||||||
if s, ok := x.(string); ok {
|
|
||||||
out = append(out, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
f.image, f.imageCreated = str("image"), str("image_created")
|
|
||||||
f.declaredImage, f.declaredCreated = str("declared_image"), str("declared_image_created")
|
|
||||||
f.downgrade, _ = h.Facts["downgrade"].(bool)
|
|
||||||
f.differs, _ = h.Facts["differs"].(bool)
|
|
||||||
f.mounts, f.ports = list("mounts"), list("ports")
|
|
||||||
f.declaredPorts, f.declaredVolumes, f.difference = list("declared_ports"), list("declared_volumes"), list("difference")
|
|
||||||
if raw, ok := h.Facts["networks"].(map[string]any); ok {
|
|
||||||
f.networks = map[string][]string{}
|
|
||||||
for name, members := range raw {
|
|
||||||
var out []string
|
|
||||||
if ms, ok := members.([]any); ok {
|
|
||||||
for _, m := range ms {
|
|
||||||
if s, ok := m.(string); ok {
|
|
||||||
out = append(out, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
f.networks[name] = out
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return f
|
|
||||||
}
|
|
||||||
|
|
||||||
// comparisonLines says a held thing's facts the way a person weighs them.
|
|
||||||
func comparisonLines(h inventory.Held) []string {
|
|
||||||
return comparisonLinesWith(h, nil, inventory.Adoption{})
|
|
||||||
}
|
|
||||||
|
|
||||||
// comparisonLinesWith is comparisonLines knowing which found networks this machine's setting keeps
|
|
||||||
// for the container (rule 4) and what the machine reports reachable, so a published port's reach
|
|
||||||
// is said beside the port (rule 1).
|
|
||||||
func comparisonLinesWith(h inventory.Held, keeps []string, reported inventory.Adoption) []string {
|
|
||||||
f := factsOf(h)
|
|
||||||
var out []string
|
|
||||||
if f.image != "" || f.declaredImage != "" {
|
|
||||||
line := fmt.Sprintf("runs %s", orNone(f.image))
|
|
||||||
if f.imageCreated != "" {
|
|
||||||
line += " (made " + day(f.imageCreated) + ")"
|
|
||||||
}
|
|
||||||
line += "; the module declares " + orNone(f.declaredImage)
|
|
||||||
switch {
|
|
||||||
case f.declaredCreated != "":
|
|
||||||
line += " (made " + day(f.declaredCreated) + ")"
|
|
||||||
case f.declaredImage != "":
|
|
||||||
line += " (not on the machine yet, so its age is unknown)"
|
|
||||||
}
|
|
||||||
if f.downgrade {
|
|
||||||
line += " — DOWNGRADE"
|
|
||||||
}
|
|
||||||
out = append(out, line)
|
|
||||||
}
|
|
||||||
names := make([]string, 0, len(f.networks))
|
|
||||||
for n := range f.networks {
|
|
||||||
names = append(names, n)
|
|
||||||
}
|
|
||||||
sort.Strings(names)
|
|
||||||
for _, n := range names {
|
|
||||||
members := f.networks[n]
|
|
||||||
if len(members) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if slices.Contains(keeps, n) {
|
|
||||||
out = append(out, fmt.Sprintf("on the network %s with %s — kept by this machine's setting, so they still reach it by name once taken",
|
|
||||||
n, strings.Join(members, ", ")))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, fmt.Sprintf("on the network %s with %s, which may reach it by name and will not once it moves to the module's own network"+
|
|
||||||
" (`settings set %s --node <node>` with {%q: {<container>: [%q]}} keeps it)",
|
|
||||||
n, strings.Join(members, ", "), h.Module, catalogue.NetworksSetting, n))
|
|
||||||
}
|
|
||||||
for _, n := range keeps {
|
|
||||||
if _, found := f.networks[n]; !found {
|
|
||||||
out = append(out, fmt.Sprintf("keeps the network %s by this machine's setting, which the found container is not on", n))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(f.ports) > 0 || len(f.declaredPorts) > 0 {
|
|
||||||
out = append(out, fmt.Sprintf("publishes %s; the module declares %s",
|
|
||||||
orNone(strings.Join(f.ports, " ")), orNone(strings.Join(f.declaredPorts, " "))))
|
|
||||||
// How far each published port reaches now, as the machine reported it: the listener the
|
|
||||||
// runtime publishes for this container. The found firewall's and the guard's rules are
|
|
||||||
// not read; what they let through is said as what was reported reachable.
|
|
||||||
var reach []string
|
|
||||||
for _, r := range reported.Reachable {
|
|
||||||
if r.By == h.Target && r.Published {
|
|
||||||
reach = append(reach, fmt.Sprintf("%s:%d (%s, container port %d)", r.Address, r.Port, r.Protocol, r.ContainerPort))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case len(reach) > 0:
|
|
||||||
line := "reachable now at " + strings.Join(reach, ", ")
|
|
||||||
if reported.Firewall != "" && reported.Firewall != "none" {
|
|
||||||
line += ", behind the found firewall (" + reported.Firewall + "), whose rules are not read"
|
|
||||||
}
|
|
||||||
out = append(out, line)
|
|
||||||
case len(f.ports) > 0 && len(reported.Reachable) > 0:
|
|
||||||
out = append(out, "not reported reachable on the machine")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(f.mounts) > 0 || len(f.declaredVolumes) > 0 {
|
|
||||||
out = append(out, fmt.Sprintf("mounts %s; the module declares %s",
|
|
||||||
orNone(strings.Join(f.mounts, " ")), orNone(strings.Join(f.declaredVolumes, " "))))
|
|
||||||
}
|
|
||||||
if f.differs {
|
|
||||||
out = append(out, "the declared content differs from the file found (- lost, + new):")
|
|
||||||
for _, d := range f.difference {
|
|
||||||
out = append(out, " "+d)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// day is a timestamp as a person reads it in a preview: its date.
|
|
||||||
func day(stamp string) string {
|
|
||||||
if len(stamp) >= 10 {
|
|
||||||
return stamp[:10]
|
|
||||||
}
|
|
||||||
return stamp
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// reportFreshFor is how old a node's account of itself may be for the flip to act on it. A
|
// reportFreshFor is how old a node's account of itself may be for the flip to act on it. A
|
||||||
@@ -720,11 +310,7 @@ func converge(ctx context.Context, open *stores, node string, yes bool, digest s
|
|||||||
}
|
}
|
||||||
derived := derivedFilter{rules: rules, foundation: with.Foundation, mesh: with.Mesh,
|
derived := derivedFilter{rules: rules, foundation: with.Foundation, mesh: with.Mesh,
|
||||||
outward: plan.PublicDomain != "", outwardLinks: with.OutwardLinks}
|
outward: plan.PublicDomain != "", outwardLinks: with.OutwardLinks}
|
||||||
filtering, err := inv.FilteringOf(ctx, node)
|
preview, saw := previewOf(node, reported, derived, plan, taken, filter, runs[filter])
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
preview, saw := previewOf(node, reported, filtering, derived, plan, taken, filter, runs[filter])
|
|
||||||
preview += "\n\n preview " + saw
|
preview += "\n\n preview " + saw
|
||||||
if !yes {
|
if !yes {
|
||||||
return preview + fmt.Sprintf("\n\nNothing has changed. Run `converge %s --yes %s` to do "+
|
return preview + fmt.Sprintf("\n\nNothing has changed. Run `converge %s --yes %s` to do "+
|
||||||
@@ -794,7 +380,7 @@ func converge(ctx context.Context, open *stores, node string, yes bool, digest s
|
|||||||
// previewOf is what converging a node will change, before it changes it, and a short digest of
|
// previewOf is what converging a node will change, before it changes it, and a short digest of
|
||||||
// what it said: every reachable thing and its fate, the modules the flip takes and the filter. The
|
// what it said: every reachable thing and its fate, the modules the flip takes and the filter. The
|
||||||
// digest is what the flip is asked to act on, so it changes whenever any of those would.
|
// digest is what the flip is asked to act on, so it changes whenever any of those would.
|
||||||
func previewOf(node string, reported inventory.Adoption, filtering inventory.Filtering, derived derivedFilter,
|
func previewOf(node string, reported inventory.Adoption, derived derivedFilter,
|
||||||
plan catalogue.Resolution, taken []string, filter string, filterAssigned bool) (string, string) {
|
plan catalogue.Resolution, taken []string, filter string, filterAssigned bool) (string, string) {
|
||||||
var said []string
|
var said []string
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
@@ -886,30 +472,6 @@ func previewOf(node string, reported inventory.Adoption, filtering inventory.Fil
|
|||||||
fmt.Fprintf(&b, " the found firewall (%s) is disabled, never flushed: its configuration stays on disk\n", fw)
|
fmt.Fprintf(&b, " the found firewall (%s) is disabled, never flushed: its configuration stays on disk\n", fw)
|
||||||
}
|
}
|
||||||
said = append(said, fmt.Sprintf("filter %s assigned=%t firewall=%s", filter, filterAssigned, fw))
|
said = append(said, fmt.Sprintf("filter %s assigned=%t firewall=%s", filter, filterAssigned, fw))
|
||||||
// What filters the machine now, and the fate of each (novox/hq ADR 0168): the found firewall
|
|
||||||
// retired, the runtime's own and bans left, and what the mesh did not write left and named —
|
|
||||||
// so the reader knows before the flip that the machine will not be filtered by the mesh alone.
|
|
||||||
if len(filtering.Filters) > 0 {
|
|
||||||
b.WriteString("\n what filters the machine now, and what the flip does to each:\n")
|
|
||||||
for _, x := range filtering.Filters {
|
|
||||||
fate := "left: " + x.Owner + "'s"
|
|
||||||
switch x.Owner {
|
|
||||||
case inventory.FilterMesh:
|
|
||||||
fate = "the mesh's guard; replaced by its filter"
|
|
||||||
case inventory.FilterFoundFirewall:
|
|
||||||
fate = "the found firewall's; retired with it"
|
|
||||||
case inventory.FilterRuntime:
|
|
||||||
fate = "the container runtime's own; left"
|
|
||||||
case inventory.FilterBan:
|
|
||||||
fate = "a ban list; left"
|
|
||||||
case inventory.FilterOther:
|
|
||||||
fate = "NOT THE MESH'S; left in force — the machine is not filtered by the mesh alone until you remove it"
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&b, " %-50s %s\n", x.Where, fate)
|
|
||||||
fmt.Fprintf(&b, " %-50s %s\n", "", x.Refuses)
|
|
||||||
said = append(said, "filter "+x.Owner+" "+x.Where)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Sorted: the same account, reported in another order, is the same preview.
|
// Sorted: the same account, reported in another order, is the same preview.
|
||||||
sort.Strings(said)
|
sort.Strings(said)
|
||||||
sum := sha256.Sum256([]byte(strings.Join(said, "\n")))
|
sum := sha256.Sum256([]byte(strings.Join(said, "\n")))
|
||||||
@@ -1034,38 +596,11 @@ func adopt(ctx context.Context, open *stores, node string) (string, error) {
|
|||||||
|
|
||||||
// takeCommand, convergeCommand and adoptCommand are the command line's adapters to the acts above.
|
// takeCommand, convergeCommand and adoptCommand are the command line's adapters to the acts above.
|
||||||
func takeCommand(ctx context.Context, args []string) error {
|
func takeCommand(ctx context.Context, args []string) error {
|
||||||
set := flag.NewFlagSet("take", flag.ContinueOnError)
|
if len(args) != 2 {
|
||||||
yes := set.Bool("yes", false, "cut over as previewed, naming the digest the preview printed after it; "+
|
return errors.New("take <node> <module>")
|
||||||
"without it the comparison is printed and nothing is taken")
|
|
||||||
downgrade := set.Bool("downgrade", false, "take it although the module's image is older than the one running")
|
|
||||||
var replace, mint stringList
|
|
||||||
set.Var(&replace, "replace", "a found file's path whose content the module may replace although it differs (repeatable; * for every one)")
|
|
||||||
set.Var(&mint, "mint", "a secret the service shall take the mesh's minted value for, although it already has its own (repeatable)")
|
|
||||||
positionals, err := parseAround(set, args)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
if len(positionals) < 2 || len(positionals) > 3 || (len(positionals) == 3 && !*yes) {
|
return runAct(ctx, func(open *stores) (string, error) { return take(ctx, open, args[0], args[1]) })
|
||||||
return errors.New("take <node> <module> [--yes <digest>] [--downgrade] [--replace <path>]... [--mint <secret>]...")
|
|
||||||
}
|
}
|
||||||
opts := takeOptions{Yes: *yes, Downgrade: *downgrade, Replace: map[string]bool{}, Mint: map[string]bool{}}
|
|
||||||
if len(positionals) == 3 {
|
|
||||||
opts.Digest = positionals[2]
|
|
||||||
}
|
|
||||||
for _, r := range replace {
|
|
||||||
opts.Replace[r] = true
|
|
||||||
}
|
|
||||||
for _, m := range mint {
|
|
||||||
opts.Mint[m] = true
|
|
||||||
}
|
|
||||||
return runAct(ctx, func(open *stores) (string, error) { return take(ctx, open, positionals[0], positionals[1], opts) })
|
|
||||||
}
|
|
||||||
|
|
||||||
// stringList is a repeatable flag.
|
|
||||||
type stringList []string
|
|
||||||
|
|
||||||
func (l *stringList) String() string { return strings.Join(*l, ",") }
|
|
||||||
func (l *stringList) Set(v string) error { *l = append(*l, v); return nil }
|
|
||||||
|
|
||||||
func convergeCommand(ctx context.Context, args []string) error {
|
func convergeCommand(ctx context.Context, args []string) error {
|
||||||
set := flag.NewFlagSet("converge", flag.ContinueOnError)
|
set := flag.NewFlagSet("converge", flag.ContinueOnError)
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ func commands(who Authenticator) http.Handler {
|
|||||||
}))
|
}))
|
||||||
// Adoption (novox/hq ADR 0100): the same acts as `take`, `converge` and `adopt`.
|
// Adoption (novox/hq ADR 0100): the same acts as `take`, `converge` and `adopt`.
|
||||||
mux.HandleFunc("POST /take", acting(who, true, func(ctx context.Context, open *stores, in request) (string, error) {
|
mux.HandleFunc("POST /take", acting(who, true, func(ctx context.Context, open *stores, in request) (string, error) {
|
||||||
return take(ctx, open, in.Node, in.Module, takeOptions{Yes: in.Yes, Digest: in.Digest})
|
return take(ctx, open, in.Node, in.Module)
|
||||||
}))
|
}))
|
||||||
mux.HandleFunc("POST /converge", acting(who, false, func(ctx context.Context, open *stores, in request) (string, error) {
|
mux.HandleFunc("POST /converge", acting(who, false, func(ctx context.Context, open *stores, in request) (string, error) {
|
||||||
return converge(ctx, open, in.Node, in.Yes, in.Digest, in.Filter)
|
return converge(ctx, open, in.Node, in.Yes, in.Digest, in.Filter)
|
||||||
@@ -124,8 +124,8 @@ func commands(who Authenticator) http.Handler {
|
|||||||
type request struct {
|
type request struct {
|
||||||
Node string `json:"node"`
|
Node string `json:"node"`
|
||||||
Module string `json:"module"`
|
Module string `json:"module"`
|
||||||
// Yes, Digest and Filter are converge's and take's: do it rather than preview it, the digest
|
// Yes, Digest and Filter are converge's: do it rather than preview it, the digest of the
|
||||||
// of the preview it acts on, and (converge) which module loads the mesh's filter.
|
// preview it acts on, and which module loads the mesh's filter.
|
||||||
Yes bool `json:"yes,omitempty"`
|
Yes bool `json:"yes,omitempty"`
|
||||||
Digest string `json:"digest,omitempty"`
|
Digest string `json:"digest,omitempty"`
|
||||||
Filter string `json:"filter,omitempty"`
|
Filter string `json:"filter,omitempty"`
|
||||||
|
|||||||
@@ -469,25 +469,10 @@ func buildOne(ctx context.Context, source buildSource, path, ref string, wait ti
|
|||||||
}
|
}
|
||||||
fmt.Printf("\n%s %s, built on %s from %s\n",
|
fmt.Printf("\n%s %s, built on %s from %s\n",
|
||||||
manifest.Module, manifest.Version, result.On, short(result.Commit))
|
manifest.Module, manifest.Version, result.On, short(result.Commit))
|
||||||
saysWhenThePolicyActs(ctx, open.inventory, manifest.Module)
|
|
||||||
fmt.Printf(" run `assign <node> %s` to put it somewhere\n", manifest.Module)
|
fmt.Printf(" run `assign <node> %s` to put it somewhere\n", manifest.Module)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// saysWhenThePolicyActs tells whoever built a module that its upgrade policy will send the
|
|
||||||
// result on at once (novox/hq issue 126, ADR 0163): a person choreographing a data move must
|
|
||||||
// know which module will not wait for them.
|
|
||||||
func saysWhenThePolicyActs(ctx context.Context, inv *inventory.Inventory, module string) {
|
|
||||||
if u, err := inv.UpgradeOf(ctx, module); err == nil && u.RollOut {
|
|
||||||
how := "one machine at a time"
|
|
||||||
if u.Together {
|
|
||||||
how = "every machine at once"
|
|
||||||
}
|
|
||||||
fmt.Printf(" %s rolls out on build: the machines running it are sent this now, %s — "+
|
|
||||||
"`upgrade %s record` first if something must move before it does\n", module, how, module)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// takeIn is what the mesh does with a build's outcome, whoever hears it: the waiting command and
|
// takeIn is what the mesh does with a build's outcome, whoever hears it: the waiting command and
|
||||||
// the daemon that follows the role's events both come here (novox/hq issue 176), so a build's
|
// the daemon that follows the role's events both come here (novox/hq issue 176), so a build's
|
||||||
// result reaches the catalogue whether or not the asker was still listening.
|
// result reaches the catalogue whether or not the asker was still listening.
|
||||||
@@ -596,8 +581,6 @@ type answers struct {
|
|||||||
// pair that answers "has it caught up", which waiting alone cannot (the sent digest is
|
// pair that answers "has it caught up", which waiting alone cannot (the sent digest is
|
||||||
// recorded at send, not at apply).
|
// recorded at send, not at apply).
|
||||||
reported []inventory.Reported
|
reported []inventory.Reported
|
||||||
// plans is what the last merges produced and where each stands (novox/hq ADR 0162).
|
|
||||||
plans []inventory.Plan
|
|
||||||
// refused is why a machine cannot be worked out at all, by name. A different thing from every
|
// refused is why a machine cannot be worked out at all, by name. A different thing from every
|
||||||
// other answer here: those are about a machine that was told something, and this is about one
|
// other answer here: those are about a machine that was told something, and this is about one
|
||||||
// that cannot be told anything — it never reaches waiting, because nothing was computed for it
|
// that cannot be told anything — it never reaches waiting, because nothing was computed for it
|
||||||
@@ -607,10 +590,6 @@ type answers struct {
|
|||||||
// a consequence of the refusals above: a node that does not resolve is not on the network, and
|
// a consequence of the refusals above: a node that does not resolve is not on the network, and
|
||||||
// a mesh whose hub is that node has no hub.
|
// a mesh whose hub is that node has no hub.
|
||||||
network string
|
network string
|
||||||
// filtered is every converged machine that is not filtered by the mesh alone (novox/hq ADR
|
|
||||||
// 0168): what filters it beyond the mesh's own, the runtime's plumbing and bans, by name — a
|
|
||||||
// predecessor's chain, a found firewall in force again. Such a machine is not "all well".
|
|
||||||
filtered map[string]inventory.Filtering
|
|
||||||
// untaken is, per machine, each assigned module whose resources the machine is holding as it
|
// untaken is, per machine, each assigned module whose resources the machine is holding as it
|
||||||
// found them, and how many — a module that was assigned, sent, and is running none of what it
|
// found them, and how many — a module that was assigned, sent, and is running none of what it
|
||||||
// declares because nothing has taken it (novox/hq ADR 0100, 04-ISSUES/125).
|
// declares because nothing has taken it (novox/hq ADR 0100, 04-ISSUES/125).
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A merge that rebuilds a base rebuilds what stands on it, through every layer, and nothing else
|
|
||||||
// (novox/hq issue 186): the runtime image moving means every module built on it moves too, and a
|
|
||||||
// module built on one of those moves as well.
|
|
||||||
func TestAMergeOfABaseTakesWhatStandsOnItAlong(t *testing.T) {
|
|
||||||
entry := func(name string) inventory.Entry {
|
|
||||||
return inventory.Entry{Manifest: catalogue.Manifest{Module: name}}
|
|
||||||
}
|
|
||||||
entries := []inventory.Entry{entry("mesh-tools"), entry("shop"), entry("shop-plugin"), entry("postgres"), entry("unrelated")}
|
|
||||||
against := map[string][]string{
|
|
||||||
"shop": {catalogue.ArtifactStoreScheme + "mesh-tools/runtime@sha256:a"},
|
|
||||||
"shop-plugin": {catalogue.ArtifactStoreScheme + "shop/runtime@sha256:b"},
|
|
||||||
"postgres": {catalogue.ArtifactStoreScheme + "mesh-tools/runtime@sha256:a"},
|
|
||||||
"unrelated": {catalogue.ArtifactStoreScheme + "alpine/base@sha256:c"},
|
|
||||||
}
|
|
||||||
got := dependentsOf([]inventory.Entry{entry("mesh-tools")}, entries, against)
|
|
||||||
var names []string
|
|
||||||
for _, e := range got {
|
|
||||||
names = append(names, e.Manifest.Module)
|
|
||||||
}
|
|
||||||
want := map[string]bool{"shop": true, "shop-plugin": true, "postgres": true}
|
|
||||||
if len(names) != len(want) {
|
|
||||||
t.Fatalf("rebuilt %v; wanted exactly the three that stand on the runtime, directly or through shop", names)
|
|
||||||
}
|
|
||||||
for _, n := range names {
|
|
||||||
if !want[n] {
|
|
||||||
t.Fatalf("%s was rebuilt and stands on nothing that moved (%v)", n, names)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// The dependents come in base order when the merge orders them: the runtime, then shop, then
|
|
||||||
// the plugin that stands on shop.
|
|
||||||
ordered := orderByBases(append([]inventory.Entry{entry("mesh-tools")}, got...), against)
|
|
||||||
pos := map[string]int{}
|
|
||||||
for i, e := range ordered {
|
|
||||||
pos[e.Manifest.Module] = i
|
|
||||||
}
|
|
||||||
if !(pos["mesh-tools"] < pos["shop"] && pos["shop"] < pos["shop-plugin"]) {
|
|
||||||
t.Fatalf("not in base order: %v", ordered)
|
|
||||||
}
|
|
||||||
// Nothing moved: nothing follows.
|
|
||||||
if more := dependentsOf(nil, entries, against); len(more) != 0 {
|
|
||||||
t.Fatalf("with nothing moved, %d module(s) were rebuilt", len(more))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -72,8 +72,6 @@ func run() error {
|
|||||||
return askCommand(ctx, args[1:])
|
return askCommand(ctx, args[1:])
|
||||||
case "builds":
|
case "builds":
|
||||||
return buildsCommand(ctx, args[1:])
|
return buildsCommand(ctx, args[1:])
|
||||||
case "plans":
|
|
||||||
return plansCommand(ctx, args[1:])
|
|
||||||
case "pin":
|
case "pin":
|
||||||
return pinCommand(ctx, args[1:], true)
|
return pinCommand(ctx, args[1:], true)
|
||||||
case "unpin":
|
case "unpin":
|
||||||
@@ -180,8 +178,7 @@ func usage() {
|
|||||||
api --issuer URL [--listen A] assign and unassign over http, for a surface that is not here
|
api --issuer URL [--listen A] assign and unassign over http, for a surface that is not here
|
||||||
assign <node> <module> put a module on a node
|
assign <node> <module> put a module on a node
|
||||||
unassign <node> <module> take it off
|
unassign <node> <module> take it off
|
||||||
take <node> <module> preview a module's cutover on an adopted node: what runs beside
|
take <node> <module> cut a module over on an adopted node, once its data has moved
|
||||||
what it declares; --yes <digest> cuts it over as previewed
|
|
||||||
converge <node> [--yes <digest>] [--filter nftables] preview, then make, an adopted node converged
|
converge <node> [--yes <digest>] [--filter nftables] preview, then make, an adopted node converged
|
||||||
adopt <node> return a converged node to adopted; what was taken stays taken
|
adopt <node> return a converged node to adopted; what was taken stays taken
|
||||||
settings set <module> <file> what a module's config should say, for the whole mesh
|
settings set <module> <file> what a module's config should say, for the whole mesh
|
||||||
@@ -206,8 +203,7 @@ func usage() {
|
|||||||
licence refresh <name> mint a new access token and seal it to every holder
|
licence refresh <name> mint a new access token and seal it to every holder
|
||||||
rotate <provision> [--consumer <n>] a new credential for every holder, both ends at once
|
rotate <provision> [--consumer <n>] a new credential for every holder, both ends at once
|
||||||
ask <module> <tool> [json] call one of a module's tools over the broker, and print its answer
|
ask <module> <tool> [json] call one of a module's tools over the broker, and print its answer
|
||||||
pin <node> <provision> <from-node> <module>
|
pin <node> <provision> <from> which node this one gets a provision from
|
||||||
which provider this one gets a provision from: the module, and its node
|
|
||||||
unpin <node> <provision> put that question back
|
unpin <node> <provision> put that question back
|
||||||
plan <node> [--files|--json] what that node would run, and why
|
plan <node> [--files|--json] what that node would run, and why
|
||||||
push [<node>] [--behind] send a node everything it should be, or only those that need it
|
push [<node>] [--behind] send a node everything it should be, or only those that need it
|
||||||
@@ -256,22 +252,12 @@ func (b builds) Built(ctx context.Context, result link.BuildResult) error {
|
|||||||
switch {
|
switch {
|
||||||
case err != nil && result.Failed != "":
|
case err != nil && result.Failed != "":
|
||||||
fmt.Printf("%s: %v\n", result.ID, err)
|
fmt.Printf("%s: %v\n", result.ID, err)
|
||||||
if result.Module != "" {
|
|
||||||
planBuilt(ctx, b.open, result.Module, result.Commit, result.Failed)
|
|
||||||
} else {
|
|
||||||
planFailedBuild(ctx, b.open, result)
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
case err != nil:
|
case err != nil:
|
||||||
fmt.Printf("%s: heard and recorded, and not registered: %v\n", result.ID, err)
|
fmt.Printf("%s: heard and recorded, and not registered: %v\n", result.ID, err)
|
||||||
if manifest.Module != "" {
|
|
||||||
planBuilt(ctx, b.open, manifest.Module, result.Commit, err.Error())
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
fmt.Printf("%s: %s %s registered, built on %s from %s\n",
|
fmt.Printf("%s: %s %s registered, built on %s from %s\n",
|
||||||
result.ID, manifest.Module, manifest.Version, result.On, short(result.Commit))
|
result.ID, manifest.Module, manifest.Version, result.On, short(result.Commit))
|
||||||
saysWhenThePolicyActs(ctx, b.inv, manifest.Module)
|
|
||||||
planBuilt(ctx, b.open, manifest.Module, result.Commit, "")
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -352,14 +352,10 @@ func settingsCommand(ctx context.Context, args []string) error {
|
|||||||
switch args[0] {
|
switch args[0] {
|
||||||
case "set":
|
case "set":
|
||||||
if len(positionals) != 2 {
|
if len(positionals) != 2 {
|
||||||
return errors.New("settings set <module> <settings.json | {…}> [--node <node>]")
|
return errors.New("settings set <module> <settings.json> [--node <node>]")
|
||||||
}
|
}
|
||||||
// A file, or the values themselves when they begin with `{` — which is how the mesh's own
|
raw, err := os.ReadFile(positionals[1])
|
||||||
// `settings` tool passes them, having no file to hand over (novox/hq issue 198).
|
if err != nil {
|
||||||
var raw []byte
|
|
||||||
if strings.HasPrefix(strings.TrimSpace(positionals[1]), "{") {
|
|
||||||
raw = []byte(positionals[1])
|
|
||||||
} else if raw, err = os.ReadFile(positionals[1]); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var values map[string]any
|
var values map[string]any
|
||||||
@@ -415,8 +411,8 @@ func describeOffers(offers []catalogue.Offer) string {
|
|||||||
// database should not change where an existing machine gets its data the day a second one
|
// database should not change where an existing machine gets its data the day a second one
|
||||||
// arrives.
|
// arrives.
|
||||||
func pinCommand(ctx context.Context, args []string, setting bool) error {
|
func pinCommand(ctx context.Context, args []string, setting bool) error {
|
||||||
if setting && len(args) != 4 {
|
if setting && len(args) != 3 {
|
||||||
return errors.New("pin <node> <provision> <from-node> <module>")
|
return errors.New("pin <node> <provision> <from-node>")
|
||||||
}
|
}
|
||||||
if !setting && len(args) != 2 {
|
if !setting && len(args) != 2 {
|
||||||
return errors.New("unpin <node> <provision>")
|
return errors.New("unpin <node> <provision>")
|
||||||
@@ -435,12 +431,17 @@ func pinCommand(ctx context.Context, args []string, setting bool) error {
|
|||||||
fmt.Printf("%s is no longer told where to get %s from\n", args[0], args[1])
|
fmt.Printf("%s is no longer told where to get %s from\n", args[0], args[1])
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// The provider's node may be this same machine: two modules beside the consumer can both
|
if args[0] == args[2] {
|
||||||
// answer a provision, and then the module is the whole question (novox/hq #258).
|
// Allowed by nothing here, and worth saying rather than resolving into a confusing
|
||||||
if err := inv.PinProvision(ctx, args[0], args[1], args[2], args[3]); err != nil {
|
// refusal later: a node providing something to itself is a node-scoped provision, and
|
||||||
|
// this field is for the other kind.
|
||||||
|
return fmt.Errorf("%s cannot get %s from itself; that would be a provision this machine "+
|
||||||
|
"provides, which does not need saying", args[0], args[1])
|
||||||
|
}
|
||||||
|
if err := inv.PinProvision(ctx, args[0], args[1], args[2]); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Printf("%s gets %s from %s/%s\n", args[0], args[1], args[2], args[3])
|
fmt.Printf("%s gets %s from %s\n", args[0], args[1], args[2])
|
||||||
fmt.Printf(" run `push %s` to send it\n", args[0])
|
fmt.Printf(" run `push %s` to send it\n", args[0])
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -715,9 +716,7 @@ func claimsFor(ctx context.Context, inv *inventory.Inventory, m catalogue.Manife
|
|||||||
claimed := seatClaimed{Seat: c.Name, Scope: c.At()}
|
claimed := seatClaimed{Seat: c.Name, Scope: c.At()}
|
||||||
if s, known := byName[c.Name]; known {
|
if s, known := byName[c.Name]; known {
|
||||||
claimed.Scope = s.Scope
|
claimed.Scope = s.Scope
|
||||||
// The verbs the runtime serves for the seat: the claim's own when it names them
|
claimed.Serves = catalogue.VerbNames(s.Serves)
|
||||||
// (ADR 0160), else every verb the seat promises, which its tools then answer.
|
|
||||||
claimed.Serves = c.ServesFor(catalogue.Manifest{Tools: catalogue.VerbNames(s.Serves)})
|
|
||||||
}
|
}
|
||||||
out = append(out, claimed)
|
out = append(out, claimed)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -232,22 +232,9 @@ func network(ctx context.Context, inv *inventory.Inventory, on map[string]bool,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// **A machine joining is on the network before it is anything else** (novox/hq ADR 0169). Its
|
|
||||||
// token was issued for its tunnel key and gave it an address, so while that token can still be
|
|
||||||
// used the hub carries it as a peer: it brings its tunnel up from the token and enrols over it.
|
|
||||||
// When the token is spent the machine is on the network by what it runs, as every other is; when
|
|
||||||
// it expires unused, the peer goes with it at the hub's next composition.
|
|
||||||
joining, err := inv.NodesWithALiveToken(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
isJoining := map[string]bool{}
|
|
||||||
for _, name := range joining {
|
|
||||||
isJoining[name] = true
|
|
||||||
}
|
|
||||||
nodes := make([]overlay.Node, 0, len(places))
|
nodes := make([]overlay.Node, 0, len(places))
|
||||||
for _, p := range places {
|
for _, p := range places {
|
||||||
if !on[p.Name] && !(isJoining[p.Name] && p.Key != "" && p.Address != "") {
|
if !on[p.Name] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
n := overlay.Node{
|
n := overlay.Node{
|
||||||
@@ -580,9 +567,6 @@ func onTheNetwork(ctx context.Context, inv *inventory.Inventory,
|
|||||||
catalogue.Node{Name: p.Name, Site: p.Site, Capabilities: caps},
|
catalogue.Node{Name: p.Name, Site: p.Site, Capabilities: caps},
|
||||||
catalogue.World{Unchecked: true, Holdings: holdings})
|
catalogue.World{Unchecked: true, Holdings: holdings})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Said, not skipped in silence: a machine dropped here loses its address, and every
|
|
||||||
// plan that names it fails in another module's words (novox/hq issue 188).
|
|
||||||
fmt.Fprintf(os.Stderr, "%s is not counted as on the network: it does not resolve: %v\n", p.Name, err)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, m := range got.Modules {
|
for _, m := range got.Modules {
|
||||||
|
|||||||
@@ -2,11 +2,9 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
|
||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -232,8 +230,6 @@ func tokenCommand(ctx context.Context, args []string) error {
|
|||||||
validFor := set.Duration("for", time.Hour, "how long the token may be used")
|
validFor := set.Duration("for", time.Hour, "how long the token may be used")
|
||||||
adopted := set.Bool("adopted", false,
|
adopted := set.Bool("adopted", false,
|
||||||
"the machine joining is in use: it is adopted, and keeps what is found on it")
|
"the machine joining is in use: it is adopted, and keeps what is found on it")
|
||||||
tunnelKey := set.String("overlay-key", "",
|
|
||||||
"the public half of the tunnel key the machine made (`nox-mesh-host key`): it joins through the tunnel")
|
|
||||||
if err := set.Parse(args[1:]); err != nil {
|
if err := set.Parse(args[1:]); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -287,14 +283,6 @@ func tokenCommand(ctx context.Context, args []string) error {
|
|||||||
default:
|
default:
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// **Through the tunnel** (novox/hq ADR 0169): the machine's key recorded, its address given, the
|
|
||||||
// hub sent it as a peer — all before the token is shown, so the tunnel answers the first time the
|
|
||||||
// machine knocks. The bus is then reached at its address on the private network.
|
|
||||||
if *tunnelKey != "" {
|
|
||||||
if made.Tunnel, made.Broker, err = throughTheTunnel(ctx, open, issued.Node, *tunnelKey, made.Broker); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
encoded, err := made.Encode()
|
encoded, err := made.Encode()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -319,66 +307,6 @@ func tokenCommand(ctx context.Context, args []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// throughTheTunnel makes a machine a peer of the hub for its token, and says what the token carries
|
|
||||||
// for it: its first tunnel, and the bus at its address on the private network (novox/hq ADR 0169).
|
|
||||||
//
|
|
||||||
// The hub is pushed here, before the token is shown. A token shown before the hub knew the key is a
|
|
||||||
// tunnel that does not answer, and a machine that cannot tell that from a bus that is down.
|
|
||||||
func throughTheTunnel(ctx context.Context, open *stores, node inventory.Node, key, busAt string) (
|
|
||||||
*token.Tunnel, string, error) {
|
|
||||||
inv := open.inventory
|
|
||||||
key = strings.TrimSpace(key)
|
|
||||||
if raw, err := base64.StdEncoding.DecodeString(key); err != nil || len(raw) != 32 {
|
|
||||||
return nil, "", fmt.Errorf("%q is not a tunnel public key: it is 32 bytes in base64, as "+
|
|
||||||
"`nox-mesh-host key` prints it", key)
|
|
||||||
}
|
|
||||||
// The bus on the private network is the hub's address at the bus's own port, so the port must be
|
|
||||||
// known before anything is recorded.
|
|
||||||
_, port, err := net.SplitHostPort(busAt)
|
|
||||||
if err != nil || port == "" {
|
|
||||||
return nil, "", fmt.Errorf("the bus's address %q has no port to reach it on", busAt)
|
|
||||||
}
|
|
||||||
places, err := inv.Overlays(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
var hub *inventory.Overlay
|
|
||||||
for i := range places {
|
|
||||||
if places[i].Hub {
|
|
||||||
hub = &places[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if hub == nil || hub.Key == "" || hub.Endpoint == "" || hub.Address == "" {
|
|
||||||
return nil, "", errors.New("this mesh has no hub with a key, an address and an endpoint to " +
|
|
||||||
"dial, so there is no tunnel to join through: place one (`overlay place <node> --hub " +
|
|
||||||
"--endpoint <host>:<port>`), or issue the token without --overlay-key")
|
|
||||||
}
|
|
||||||
if err := inv.RecordOverlayKey(ctx, node.ID, key); err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
if err := inv.BindTokenToKey(ctx, node.ID, key); err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
cidr, err := overlayRange(ctx, inv)
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
address, err := inv.AssignAddress(ctx, node.ID, cidr)
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
if err := sendTo(ctx, open, []string{hub.Name}); err != nil {
|
|
||||||
return nil, "", fmt.Errorf("%s was made a peer of the hub, and the hub could not be sent "+
|
|
||||||
"it, so the tunnel would not answer — the token is not shown; issue it again once %s "+
|
|
||||||
"can be pushed: %w", node.Name, hub.Name, err)
|
|
||||||
}
|
|
||||||
// An address, not a name — nothing resolves before the machine has joined (novox/hq ADR 0004).
|
|
||||||
return &token.Tunnel{
|
|
||||||
Key: key, Address: address + "/32", Range: cidr,
|
|
||||||
HubKey: hub.Key, HubEndpoint: hub.Endpoint,
|
|
||||||
}, net.JoinHostPort(hub.Address, port), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// issueFor is the inventory's half of issuing a token: the record, made when it is new, adopted
|
// issueFor is the inventory's half of issuing a token: the record, made when it is new, adopted
|
||||||
// when the operator says so, and the one-time secret for it. The node in what it returns carries
|
// when the operator says so, and the one-time secret for it. The node in what it returns carries
|
||||||
// its mode, which is what the token says.
|
// its mode, which is what the token says.
|
||||||
|
|||||||
+18
-69
@@ -7,7 +7,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -186,12 +185,8 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso
|
|||||||
// Settings for everything that resolved, including modules nobody assigned directly: a
|
// Settings for everything that resolved, including modules nobody assigned directly: a
|
||||||
// requirement pulled in by something else is still configurable, and finding out that it is
|
// requirement pulled in by something else is still configurable, and finding out that it is
|
||||||
// not only when you try would be an arbitrary line nobody could predict.
|
// not only when you try would be an arbitrary line nobody could predict.
|
||||||
//
|
|
||||||
// A setting that reaches nothing, or cannot compose with the definition it was stored for,
|
|
||||||
// no longer refuses the machine here: it is judged where it is stored, and a definition that
|
|
||||||
// moved under it costs that module its place in the declaration, said by name (novox/hq ADR
|
|
||||||
// 0163, rule 6 — see Compose).
|
|
||||||
settings := catalogue.SettingsBy{}
|
settings := catalogue.SettingsBy{}
|
||||||
|
var stray []string
|
||||||
for _, m := range resolved.Modules {
|
for _, m := range resolved.Modules {
|
||||||
layers, err := inv.SettingsFor(ctx, nodeName, m.Module)
|
layers, err := inv.SettingsFor(ctx, nodeName, m.Module)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -201,6 +196,18 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
settings[m.Module] = layers
|
settings[m.Module] = layers
|
||||||
|
stray = append(stray, catalogue.UnusedSettings(m, layers)...)
|
||||||
|
}
|
||||||
|
if len(stray) > 0 {
|
||||||
|
// Somebody set something that reaches no file. Said here rather than discovered by the
|
||||||
|
// machine not behaving differently, which is the slowest way there is.
|
||||||
|
//
|
||||||
|
// Marked like a set that will not compose, and for the same reason: it is a standing fact
|
||||||
|
// about this node's own configuration, not a question the mesh could not answer. A gatherer
|
||||||
|
// passes over it as it always did — one node's stray setting must not stop every other node
|
||||||
|
// being described (novox/hq 04-ISSUES/152).
|
||||||
|
return catalogue.Resolution{}, nil, notResolvable{fmt.Errorf(
|
||||||
|
"these settings reach nothing:\n - %s", strings.Join(stray, "\n - "))}
|
||||||
}
|
}
|
||||||
return resolved, settings, nil
|
return resolved, settings, nil
|
||||||
}
|
}
|
||||||
@@ -274,9 +281,8 @@ func theRestOfTheMesh(ctx context.Context, inv *inventory.Inventory,
|
|||||||
for _, o := range others {
|
for _, o := range others {
|
||||||
got, err := catalogue.Resolve(shelf, o.assigned, o.node, catalogue.World{Unchecked: true, Holdings: holdings})
|
got, err := catalogue.Resolve(shelf, o.assigned, o.node, catalogue.World{Unchecked: true, Holdings: holdings})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Said, not skipped: a machine dropped here offers nothing and holds nothing as far
|
// Their set does not resolve for some other reason. Not this node's problem to
|
||||||
// as every other machine's plan can tell (novox/hq issue 188).
|
// report, and nothing of theirs is running, so it offers nothing.
|
||||||
fmt.Fprintf(os.Stderr, "%s is left out of the rest of the mesh: it does not resolve: %v\n", o.node.Name, err)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
firstHeld = append(firstHeld, got.Claims...)
|
firstHeld = append(firstHeld, got.Claims...)
|
||||||
@@ -307,22 +313,8 @@ func theRestOfTheMesh(ctx context.Context, inv *inventory.Inventory,
|
|||||||
world := catalogue.World{Offered: offered, Held: firstHeld, Holdings: holdings}
|
world := catalogue.World{Offered: offered, Held: firstHeld, Holdings: holdings}
|
||||||
var held []catalogue.Held
|
var held []catalogue.Held
|
||||||
for _, o := range others {
|
for _, o := range others {
|
||||||
// Each machine is resolved with its own pins, as its plan is: a machine that needs one to
|
got, err := catalogue.Resolve(shelf, o.assigned, o.node, world)
|
||||||
// settle two providers would otherwise be refused here and vanish from the mesh — every
|
|
||||||
// seat it holds unheld, every build that needs one refused (2026-10-01, the control node;
|
|
||||||
// novox/hq issue 188).
|
|
||||||
theirs := world
|
|
||||||
if pins, err := inv.PinsFor(ctx, o.node.Name); err == nil {
|
|
||||||
theirs.Pinned = pins
|
|
||||||
}
|
|
||||||
got, err := catalogue.Resolve(shelf, o.assigned, o.node, theirs)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Said only for the whole-mesh view. With one machine excluded, the others are
|
|
||||||
// resolved without its offers, and one that consumes them cannot resolve here by
|
|
||||||
// design — that is not the machine being dropped, it is the view being partial.
|
|
||||||
if exclude == "" {
|
|
||||||
fmt.Fprintf(os.Stderr, "%s is left out of the rest of the mesh: it does not resolve: %v\n", o.node.Name, err)
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
held = append(held, got.Claims...)
|
held = append(held, got.Claims...)
|
||||||
@@ -396,33 +388,7 @@ func declarationWith(ctx context.Context, open *stores, node string,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return sendable{}, err
|
return sendable{}, err
|
||||||
}
|
}
|
||||||
return sendable{Resources: composed.Resources, Adoption: adoption,
|
return sendable{Resources: composed.Resources, Adoption: adoption}, nil
|
||||||
Received: composed.Received, Mesh: with.Mesh,
|
|
||||||
LeftOut: sortedKeysOf(composed.LeftOut), leftOutWhy: composed.LeftOut}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// sortedKeysOf is a map's keys, sorted — so what a declaration says it left out does not move
|
|
||||||
// for a reordering nobody made.
|
|
||||||
func sortedKeysOf(m map[string]string) []string {
|
|
||||||
if len(m) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := make([]string, 0, len(m))
|
|
||||||
for k := range m {
|
|
||||||
out = append(out, k)
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// reportLeftOut says which of a machine's modules its declaration leaves out and why (novox/hq ADR
|
|
||||||
// 0163, rule 6), one line each: the machine is told everything else, and is told it was left out.
|
|
||||||
func reportLeftOut(node string, declared sendable) {
|
|
||||||
for _, m := range declared.LeftOut {
|
|
||||||
fmt.Printf("%s: %s left out — a setting stored for it cannot compose with its definition; "+
|
|
||||||
"what the machine holds for it is kept and its containers are untouched. %s\n",
|
|
||||||
node, m, declared.leftOutWhy[m])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderingFor is everything a node's declaration is composed with, and the node's record.
|
// renderingFor is everything a node's declaration is composed with, and the node's record.
|
||||||
@@ -462,10 +428,7 @@ func renderingFor(ctx context.Context, open *stores, node string,
|
|||||||
for _, m := range plan.Modules {
|
for _, m := range plan.Modules {
|
||||||
g, err := catalogue.GivenPorts(m, settings[m.Module])
|
g, err := catalogue.GivenPorts(m, settings[m.Module])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// A given port its definition no longer publishes: the module is left out of the
|
return catalogue.Rendering{}, inventory.Node{}, err
|
||||||
// declaration, by name, when it is composed (novox/hq ADR 0163, rule 6) — never the
|
|
||||||
// machine refused here for it.
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
if g != nil {
|
if g != nil {
|
||||||
given[m.Module] = g
|
given[m.Module] = g
|
||||||
@@ -1020,20 +983,6 @@ func planCommand(ctx context.Context, args []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Which modules a push would leave out, and why — said before the plan, since the plan is of
|
|
||||||
// what the machine would be told (novox/hq ADR 0163, rule 6). Judged, never composed: `plan`
|
|
||||||
// without --json allocates nothing.
|
|
||||||
if record, err := open.inventory.NodeByName(ctx, args[0]); err == nil {
|
|
||||||
left := plan.LeftOut(settings, record.Adopted)
|
|
||||||
reportLeftOut(args[0], sendable{LeftOut: sortedKeysOf(left), leftOutWhy: left})
|
|
||||||
}
|
|
||||||
// And a setting that reaches nothing — refused where it is stored, and said here for one
|
|
||||||
// stored before its definition moved from under it.
|
|
||||||
for _, m := range plan.Modules {
|
|
||||||
for _, stray := range catalogue.UnusedSettings(m, settings[m.Module]) {
|
|
||||||
fmt.Printf("%s: a setting reaches nothing — %s\n", args[0], stray)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Printf("%s would run:\n", args[0])
|
fmt.Printf("%s would run:\n", args[0])
|
||||||
for _, m := range plan.Modules {
|
for _, m := range plan.Modules {
|
||||||
fmt.Printf(" %-20s %s\n", m.Module, plan.Because[m.Module])
|
fmt.Printf(" %-20s %s\n", m.Module, plan.Because[m.Module])
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -113,10 +112,7 @@ func serve(ctx context.Context) error {
|
|||||||
// while everything else about it looks correct.
|
// while everything else about it looks correct.
|
||||||
// And build results nobody was waiting for. A build triggered any other way than `build`
|
// And build results nobody was waiting for. A build triggered any other way than `build`
|
||||||
// would otherwise be reported into the void, which is the same as not reporting it.
|
// would otherwise be reported into the void, which is the same as not reporting it.
|
||||||
server.Records(builds{inv, open})
|
server.Records(builds{inv})
|
||||||
// Open plans move on a timer as well as on outcomes (novox/hq ADR 0162): a tier waiting for
|
|
||||||
// machines to report moves when they have, and a plan left by a replaced controller resumes.
|
|
||||||
go planTicker(ctx, open)
|
|
||||||
// And what the catalogue decided a build meant. The builder's own result is already handled
|
// And what the catalogue decided a build meant. The builder's own result is already handled
|
||||||
// above; this is the other half — the control plane is the only one of the three that knows
|
// above; this is the other half — the control plane is the only one of the three that knows
|
||||||
// which machines run the thing, so it is the one that acts (novox/hq ADR 0072).
|
// which machines run the thing, so it is the one that acts (novox/hq ADR 0072).
|
||||||
@@ -353,11 +349,7 @@ func pushCommand(ctx context.Context, args []string) error {
|
|||||||
// The private network is in here with everything else. It used to be composed separately
|
// The private network is in here with everything else. It used to be composed separately
|
||||||
// and prepended, which meant every machine with an address was on it and no machine could
|
// and prepended, which meant every machine with an address was on it and no machine could
|
||||||
// be kept off. It is a module now, so it arrives the way a module does.
|
// be kept off. It is a module now, so it arrives the way a module does.
|
||||||
declared, err := declarationWith(held, open, node, plan, settings, gens, Allocating)
|
return declarationWith(held, open, node, plan, settings, gens, Allocating)
|
||||||
if err == nil {
|
|
||||||
reportLeftOut(node, declared)
|
|
||||||
}
|
|
||||||
return declared, err
|
|
||||||
})
|
})
|
||||||
|
|
||||||
sentDigest := map[string]string{}
|
sentDigest := map[string]string{}
|
||||||
@@ -391,11 +383,6 @@ func pushCommand(ctx context.Context, args []string) error {
|
|||||||
}
|
}
|
||||||
release()
|
release()
|
||||||
fmt.Printf("\n%d node(s) told\n", len(sending))
|
fmt.Printf("\n%d node(s) told\n", len(sending))
|
||||||
// And each machine's memberships, as every other send does (ADR 0160): a push is the one most
|
|
||||||
// operators run, and on 2026-10-01 it was the one path that issued none.
|
|
||||||
if err := issueMemberships(ctx, open, server, sending); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// **A named push leaves the mesh consistent, not just the machine it named** (novox/hq
|
// **A named push leaves the mesh consistent, not just the machine it named** (novox/hq
|
||||||
// issue 057, ADR 0083). Assigning a cross-node consumer mints a provision, and the PROVIDER's
|
// issue 057, ADR 0083). Assigning a cross-node consumer mints a provision, and the PROVIDER's
|
||||||
@@ -458,11 +445,7 @@ func pushCommand(ctx context.Context, args []string) error {
|
|||||||
return sendable{}, err
|
return sendable{}, err
|
||||||
}
|
}
|
||||||
reportUnhostable(node, plan)
|
reportUnhostable(node, plan)
|
||||||
declared, err := declarationWith(held, open, node, plan, settings, gens, Allocating)
|
return declarationWith(held, open, node, plan, settings, gens, Allocating)
|
||||||
if err == nil {
|
|
||||||
reportLeftOut(node, declared)
|
|
||||||
}
|
|
||||||
return declared, err
|
|
||||||
},
|
},
|
||||||
func(s readyNode, body []byte) error {
|
func(s readyNode, body []byte) error {
|
||||||
if err := link.Declare(ctx, server.Bus(), ident, s.node, body,
|
if err := link.Declare(ctx, server.Bus(), ident, s.node, body,
|
||||||
@@ -674,7 +657,6 @@ func sendTo(ctx context.Context, open *stores, names []string) error {
|
|||||||
refusals = append(refusals, fmt.Sprintf("%s:\n%v", name, err))
|
refusals = append(refusals, fmt.Sprintf("%s:\n%v", name, err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
reportLeftOut(name, declared)
|
|
||||||
sending = append(sending, readyNode{name, declared})
|
sending = append(sending, readyNode{name, declared})
|
||||||
}
|
}
|
||||||
if len(refusals) > 0 {
|
if len(refusals) > 0 {
|
||||||
@@ -708,68 +690,6 @@ func sendTo(ctx context.Context, open *stores, names []string) error {
|
|||||||
}
|
}
|
||||||
fmt.Printf(" sent %s %d resource(s)\n", s.node, len(s.declared.Resources))
|
fmt.Printf(" sent %s %d resource(s)\n", s.node, len(s.declared.Resources))
|
||||||
}
|
}
|
||||||
// And every assignment on those machines its membership (novox/hq ADR 0160): composed from the
|
|
||||||
// same records the bus's accounts are, so what a runtime serves and what its account may are one
|
|
||||||
// composition. Issued after the declaration, because the runtime it is for arrives with it.
|
|
||||||
return issueMemberships(ctx, open, server, sending)
|
|
||||||
}
|
|
||||||
|
|
||||||
// issueMemberships publishes the membership of every module on the machines just sent.
|
|
||||||
//
|
|
||||||
// Each carries what its module receives and the private network's addresses, from the same
|
|
||||||
// composition as the declaration it was sent (novox/hq ADR 0167): a provider reads what it is
|
|
||||||
// given on the bus, and the file written beside it says the same thing.
|
|
||||||
func issueMemberships(ctx context.Context, open *stores, server *link.Server, sent []readyNode) error {
|
|
||||||
records, err := open.inventory.BusRecords(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
where := broker.PlacementsOf(records, records.Interchangeable)
|
|
||||||
bus, ok := server.Bus().(link.OverNATS)
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// The declarations are sent and recorded by now; a membership that cannot be issued is said
|
|
||||||
// and does not unsay them. Every runtime without one serves the shape it derives (ADR 0160), so
|
|
||||||
// the push stands, the first failure is named once, and the next push tries again.
|
|
||||||
issued, failed := 0, 0
|
|
||||||
var first error
|
|
||||||
for _, s := range sent {
|
|
||||||
node := s.node
|
|
||||||
for _, d := range records.Assigned[node] {
|
|
||||||
membership := broker.MembershipFor(node, d, where)
|
|
||||||
membership.Mesh = s.declared.Mesh
|
|
||||||
for requirement, given := range s.declared.Received[d.Module] {
|
|
||||||
raw, err := json.Marshal(given)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if membership.Receives == nil {
|
|
||||||
membership.Receives = map[string]json.RawMessage{}
|
|
||||||
}
|
|
||||||
membership.Receives[requirement] = raw
|
|
||||||
}
|
|
||||||
body, err := json.Marshal(membership)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := bus.PublishMembership(ctx, node, d.Module, body); err != nil {
|
|
||||||
if first == nil {
|
|
||||||
first = err
|
|
||||||
}
|
|
||||||
failed++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
issued++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if issued > 0 {
|
|
||||||
fmt.Printf(" issued %d membership(s)\n", issued)
|
|
||||||
}
|
|
||||||
if failed > 0 {
|
|
||||||
fmt.Printf(" %d membership(s) could not be issued; the first: %v — the machines keep what "+
|
|
||||||
"they derive until the next push\n", failed, first)
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package main
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
|
||||||
"sort"
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -40,9 +39,6 @@ type meshStatus struct {
|
|||||||
// whose is older is still working — and Waiting cannot tell those apart, because the sent
|
// whose is older is still working — and Waiting cannot tell those apart, because the sent
|
||||||
// digest is recorded at send, not at apply.
|
// digest is recorded at send, not at apply.
|
||||||
Reported []machineReported `json:"reported"`
|
Reported []machineReported `json:"reported"`
|
||||||
// Plans is what the last merges produced and where each stands (novox/hq ADR 0162): the
|
|
||||||
// open ones first, each saying its tier, what it waits for, and whether it has waited too long.
|
|
||||||
Plans []planStatus `json:"plans"`
|
|
||||||
// Unresolved is every machine that cannot be worked out at all, with what the mesh said when
|
// Unresolved is every machine that cannot be worked out at all, with what the mesh said when
|
||||||
// it tried. **A machine here is in none of the lists above**: nothing was computed for it, so
|
// it tried. **A machine here is in none of the lists above**: nothing was computed for it, so
|
||||||
// there is nothing to compare it against and nothing it can be behind — which is why a
|
// there is nothing to compare it against and nothing it can be behind — which is why a
|
||||||
@@ -67,21 +63,6 @@ type meshStatus struct {
|
|||||||
// **A document without this said an outage was a well mesh.** Read from what each machine
|
// **A document without this said an outage was a well mesh.** Read from what each machine
|
||||||
// reported, so it is the machine's account and not the mesh's take-time listing.
|
// reported, so it is the machine's account and not the mesh's take-time listing.
|
||||||
Untaken []machineUntaken `json:"untaken,omitempty"`
|
Untaken []machineUntaken `json:"untaken,omitempty"`
|
||||||
// Filtered is every converged machine that is not filtered by the mesh alone (novox/hq ADR
|
|
||||||
// 0168), one entry per rule set the mesh did not write — the found firewall in force again,
|
|
||||||
// or a chain nobody speaks for. Absent when every converged machine is filtered by the mesh
|
|
||||||
// alone. A document without this called a machine well while a predecessor's chain refused
|
|
||||||
// what the mesh declared open.
|
|
||||||
Filtered []machineFiltered `json:"filtered,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// machineFiltered is one rule set on a converged machine that the mesh did not write and that
|
|
||||||
// refuses traffic: where it is, whose the host reads it as, and what it refuses.
|
|
||||||
type machineFiltered struct {
|
|
||||||
Node string `json:"node"`
|
|
||||||
Where string `json:"where"`
|
|
||||||
Owner string `json:"owner"`
|
|
||||||
Refuses string `json:"refuses"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// machineUntaken is one module a machine is holding rather than running, and how many resources of
|
// machineUntaken is one module a machine is holding rather than running, and how many resources of
|
||||||
@@ -171,7 +152,7 @@ func statusAsJSON(asked answers) ([]byte, error) {
|
|||||||
out := meshStatus{Machines: len(nodes), Wrong: []machineDoing{},
|
out := meshStatus{Machines: len(nodes), Wrong: []machineDoing{},
|
||||||
Quiet: []machineQuiet{}, Behind: []moduleBehind{}, Waiting: []machineWaiting{},
|
Quiet: []machineQuiet{}, Behind: []moduleBehind{}, Waiting: []machineWaiting{},
|
||||||
Reported: []machineReported{}, Unresolved: []machineUnresolved{},
|
Reported: []machineReported{}, Unresolved: []machineUnresolved{},
|
||||||
Network: asked.network, Adopted: adoptedNodes(nodes), Plans: planStatuses(asked.plans, time.Now())}
|
Network: asked.network, Adopted: adoptedNodes(nodes)}
|
||||||
// In a stated order, so two readings of an unchanged mesh are the same document.
|
// In a stated order, so two readings of an unchanged mesh are the same document.
|
||||||
untakenNodes := make([]string, 0, len(asked.untaken))
|
untakenNodes := make([]string, 0, len(asked.untaken))
|
||||||
for name := range asked.untaken {
|
for name := range asked.untaken {
|
||||||
@@ -189,21 +170,6 @@ func statusAsJSON(asked answers) ([]byte, error) {
|
|||||||
machineUntaken{Node: name, Module: m, Held: asked.untaken[name][m]})
|
machineUntaken{Node: name, Module: m, Held: asked.untaken[name][m]})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
filteredNodes := make([]string, 0, len(asked.filtered))
|
|
||||||
for name := range asked.filtered {
|
|
||||||
filteredNodes = append(filteredNodes, name)
|
|
||||||
}
|
|
||||||
sort.Strings(filteredNodes)
|
|
||||||
for _, name := range filteredNodes {
|
|
||||||
f := asked.filtered[name]
|
|
||||||
if fw := f.FoundFirewall; fw != nil && fw.Active {
|
|
||||||
out.Filtered = append(out.Filtered, machineFiltered{Node: name, Where: "the found firewall",
|
|
||||||
Owner: inventory.FilterFoundFirewall, Refuses: fw.Kind + " is in force again"})
|
|
||||||
}
|
|
||||||
for _, x := range f.Others() {
|
|
||||||
out.Filtered = append(out.Filtered, machineFiltered{Node: name, Where: x.Where, Owner: x.Owner, Refuses: x.Refuses})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for name := range asked.refused {
|
for name := range asked.refused {
|
||||||
out.Unresolved = append(out.Unresolved, machineUnresolved{
|
out.Unresolved = append(out.Unresolved, machineUnresolved{
|
||||||
Node: name, Problem: asked.refused[name]})
|
Node: name, Problem: asked.refused[name]})
|
||||||
|
|||||||
@@ -167,46 +167,3 @@ func TestAMachineFailingTheSameWayIsSaidToBeStuck(t *testing.T) {
|
|||||||
t.Fatalf("one failure is not stuck: %v", once)
|
t.Fatalf("one failure is not stuck: %v", once)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A converged machine something other than the mesh filters is named, per rule set, and is not
|
|
||||||
// well (novox/hq ADR 0168); one filtered by the mesh alone is not in the list.
|
|
||||||
func TestAMachineNotFilteredByTheMeshAloneIsNamedAndNotWell(t *testing.T) {
|
|
||||||
alone := inventory.Filtering{Filters: []inventory.Filter{
|
|
||||||
{Where: "table inet mesh, chain input", Owner: inventory.FilterMesh, Refuses: "policy drop"},
|
|
||||||
{Where: "table ip filter, chain DOCKER", Owner: inventory.FilterRuntime, Refuses: "drop"},
|
|
||||||
{Where: "table ip filter, chain f2b-sshd", Owner: inventory.FilterBan, Refuses: "ip saddr 192.0.2.1 reject"},
|
|
||||||
}}
|
|
||||||
if !alone.Alone() {
|
|
||||||
t.Fatal("the mesh's own, the runtime's and a ban are not the mesh alone")
|
|
||||||
}
|
|
||||||
notAlone := inventory.Filtering{
|
|
||||||
Filters: append(alone.Filters, inventory.Filter{Where: "chain HAL-MESH-ONLY (iptables-legacy)",
|
|
||||||
Owner: inventory.FilterOther, Refuses: `-A HAL-MESH-ONLY -m comment --comment "not public" -j DROP`}),
|
|
||||||
FoundFirewall: &inventory.FoundFirewall{Kind: "ufw", Active: true},
|
|
||||||
}
|
|
||||||
asked := answers{nodes: []inventory.Node{{Name: "home-server"}, {Name: "laptop"}},
|
|
||||||
filtered: map[string]inventory.Filtering{"home-server": notAlone}}
|
|
||||||
if asked.well() {
|
|
||||||
t.Fatal("a machine not filtered by the mesh alone reads as well")
|
|
||||||
}
|
|
||||||
body, err := statusAsJSON(asked)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var parsed struct {
|
|
||||||
Filtered []map[string]string `json:"filtered"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(parsed.Filtered) != 2 {
|
|
||||||
t.Fatalf("filtered: %v", parsed.Filtered)
|
|
||||||
}
|
|
||||||
if parsed.Filtered[0]["node"] != "home-server" || parsed.Filtered[0]["owner"] != inventory.FilterFoundFirewall ||
|
|
||||||
parsed.Filtered[1]["where"] != "chain HAL-MESH-ONLY (iptables-legacy)" || parsed.Filtered[1]["owner"] != inventory.FilterOther {
|
|
||||||
t.Fatalf("filtered: %v", parsed.Filtered)
|
|
||||||
}
|
|
||||||
if body, _ := statusAsJSON(answers{nodes: asked.nodes}); strings.Contains(string(body), `"filtered"`) {
|
|
||||||
t.Fatal("a mesh filtered by itself alone carries a filtered list")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,742 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
|
||||||
"github.com/novox/mesh-controller/internal/link"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A merge produces a tiered plan the mesh keeps (novox/hq ADR 0162).
|
|
||||||
//
|
|
||||||
// The handler that hears the merge computes the plan from the catalogue's one dependency relation,
|
|
||||||
// writes it to the store, asks the first tier and returns — the receive loop is never held by a
|
|
||||||
// build. Every outcome taken in advances the plan it belongs to; a ticker advances what outcomes
|
|
||||||
// alone cannot (a tier waiting for machines to report); a controller replaced mid-plan finds the
|
|
||||||
// plan where it left it.
|
|
||||||
|
|
||||||
// planWaitBound is how long a plan may wait on one thing before `status` names it red.
|
|
||||||
const planWaitBound = 30 * time.Minute
|
|
||||||
|
|
||||||
// tiersOf sorts a set of modules into tiers along the ordering edges among them: tier 0 depends
|
|
||||||
// on nothing else in the set, tier 1 only on tier 0, and so on. An edge to a module outside the set says
|
|
||||||
// nothing about the order inside it. A cycle — which the catalogue should never produce — puts
|
|
||||||
// what remains in one last tier rather than losing it, and is said by the caller.
|
|
||||||
func tiersOf(set []string, edges []inventory.Edge) [][]string {
|
|
||||||
in := map[string]bool{}
|
|
||||||
for _, m := range set {
|
|
||||||
in[m] = true
|
|
||||||
}
|
|
||||||
deps := map[string]map[string]bool{}
|
|
||||||
for _, m := range set {
|
|
||||||
deps[m] = map[string]bool{}
|
|
||||||
}
|
|
||||||
for _, e := range edges {
|
|
||||||
// A code dependency — B packages A's source — rebuilds B with A, in the same tier: B's
|
|
||||||
// build needs nothing of A's first. The other kinds order: stands-on and declared after
|
|
||||||
// the base is built, built-by after the build machine is built and running — except for
|
|
||||||
// what the build machine itself stands on. The runtime image is built by the builder and
|
|
||||||
// the builder is built on the runtime image; the image comes first, built by the builder
|
|
||||||
// that is running, which is the only one there could be.
|
|
||||||
if !in[e.From] || !in[e.To] || e.From == e.To || e.Kind == inventory.EdgePackages {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if e.Kind == inventory.EdgeBuiltBy && isBaseOf(e.From, e.To, edges, in) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
deps[e.From][e.To] = true
|
|
||||||
}
|
|
||||||
placed := map[string]bool{}
|
|
||||||
var tiers [][]string
|
|
||||||
for len(placed) < len(set) {
|
|
||||||
var tier []string
|
|
||||||
for _, m := range set {
|
|
||||||
if placed[m] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
free := true
|
|
||||||
for d := range deps[m] {
|
|
||||||
if !placed[d] {
|
|
||||||
free = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if free {
|
|
||||||
tier = append(tier, m)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(tier) == 0 {
|
|
||||||
// A cycle: everything left, together, and the caller says so.
|
|
||||||
for _, m := range set {
|
|
||||||
if !placed[m] {
|
|
||||||
tier = append(tier, m)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(tier)
|
|
||||||
for _, m := range tier {
|
|
||||||
placed[m] = true
|
|
||||||
}
|
|
||||||
tiers = append(tiers, tier)
|
|
||||||
}
|
|
||||||
return tiers
|
|
||||||
}
|
|
||||||
|
|
||||||
// isBaseOf says whether `to` stands on `base`, directly or through other bases in the set, along
|
|
||||||
// the build edges alone.
|
|
||||||
func isBaseOf(base, to string, edges []inventory.Edge, in map[string]bool) bool {
|
|
||||||
seen := map[string]bool{}
|
|
||||||
var walk func(string) bool
|
|
||||||
walk = func(m string) bool {
|
|
||||||
if m == base {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if seen[m] {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
seen[m] = true
|
|
||||||
for _, e := range edges {
|
|
||||||
if e.From == m && in[e.To] && (e.Kind == inventory.EdgeStandsOn || e.Kind == inventory.EdgeDeclared) && walk(e.To) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return walk(to)
|
|
||||||
}
|
|
||||||
|
|
||||||
// reachableFrom is the moved modules plus everything that depends on them, through every layer:
|
|
||||||
// what a merge rebuilds. Along the code and build edges only: a module *built by* the build machine
|
|
||||||
// is not changed by a new build machine, so a built-by edge orders and gates a plan and never
|
|
||||||
// widens it — the first plan of 2026-10-01 took the whole catalogue along for a controller change.
|
|
||||||
func reachableFrom(moved []string, edges []inventory.Edge) []string {
|
|
||||||
in := map[string]bool{}
|
|
||||||
for _, m := range moved {
|
|
||||||
in[m] = true
|
|
||||||
}
|
|
||||||
for grew := true; grew; {
|
|
||||||
grew = false
|
|
||||||
for _, e := range edges {
|
|
||||||
if e.Kind == inventory.EdgeBuiltBy {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if in[e.To] && !in[e.From] {
|
|
||||||
in[e.From] = true
|
|
||||||
grew = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out := make([]string, 0, len(in))
|
|
||||||
for m := range in {
|
|
||||||
out = append(out, m)
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// hasCycle says whether the tiers' last tier holds modules that still depend on each other.
|
|
||||||
func hasCycle(tiers [][]string, edges []inventory.Edge) bool {
|
|
||||||
if len(tiers) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
last := map[string]bool{}
|
|
||||||
for _, m := range tiers[len(tiers)-1] {
|
|
||||||
last[m] = true
|
|
||||||
}
|
|
||||||
for _, e := range edges {
|
|
||||||
if last[e.From] && last[e.To] {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// planFor is the plan a merge produces: the moved modules and everything reachable from them,
|
|
||||||
// tiered, with the merge it answers.
|
|
||||||
func planOfMerge(m link.SourceMoved, moved []string, edges []inventory.Edge) inventory.Plan {
|
|
||||||
set := reachableFrom(moved, edges)
|
|
||||||
tiers := tiersOf(set, edges)
|
|
||||||
modules := map[string]*inventory.PlanModule{}
|
|
||||||
for _, name := range set {
|
|
||||||
modules[name] = &inventory.PlanModule{}
|
|
||||||
}
|
|
||||||
return inventory.Plan{
|
|
||||||
ID: fmt.Sprintf("plan-%d", time.Now().UnixNano()),
|
|
||||||
Repository: m.Owner + "/" + m.Repo,
|
|
||||||
Commit: m.Commit,
|
|
||||||
Created: time.Now().UTC(),
|
|
||||||
State: inventory.PlanBuilding,
|
|
||||||
Tiers: tiers,
|
|
||||||
Modules: modules,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// gates is what the next tier needs running from this one: a module of the tier that a later
|
|
||||||
// tier is built by — the runtime dependency — and whose policy rolls it out, must be applied by
|
|
||||||
// the machines running it before the next tier is asked. A base an image stands on need only be
|
|
||||||
// built; a source another module packages need not even be that.
|
|
||||||
func gates(p inventory.Plan, edges []inventory.Edge, rollsOut func(string) bool) []string {
|
|
||||||
if p.Tier >= len(p.Tiers) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
inTier := map[string]bool{}
|
|
||||||
all := map[string]bool{}
|
|
||||||
for _, tier := range p.Tiers {
|
|
||||||
for _, m := range tier {
|
|
||||||
all[m] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, m := range p.Tiers[p.Tier] {
|
|
||||||
inTier[m] = true
|
|
||||||
}
|
|
||||||
later := map[string]bool{}
|
|
||||||
for _, tier := range p.Tiers[p.Tier+1:] {
|
|
||||||
for _, m := range tier {
|
|
||||||
later[m] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
seen := map[string]bool{}
|
|
||||||
var out []string
|
|
||||||
for _, e := range edges {
|
|
||||||
if later[e.From] && inTier[e.To] && e.Kind == inventory.EdgeBuiltBy && !seen[e.To] && rollsOut(e.To) &&
|
|
||||||
!isBaseOf(e.From, e.To, edges, all) {
|
|
||||||
seen[e.To] = true
|
|
||||||
out = append(out, e.To)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// applied says whether every machine running the module has reported since the module was built.
|
|
||||||
func applied(module string, builtAt time.Time, running []string, reports []inventory.Reported) (bool, []string) {
|
|
||||||
at := map[string]*time.Time{}
|
|
||||||
for _, r := range reports {
|
|
||||||
at[r.Node] = r.At
|
|
||||||
}
|
|
||||||
var waiting []string
|
|
||||||
for _, n := range running {
|
|
||||||
if t := at[n]; t == nil || t.Before(builtAt) {
|
|
||||||
waiting = append(waiting, n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return len(waiting) == 0, waiting
|
|
||||||
}
|
|
||||||
|
|
||||||
// askTier asks the build machine for every module of the tier, and marks each asked. A module
|
|
||||||
// the catalogue no longer holds, or whose ask could not be made, is a failure of the plan: a tier
|
|
||||||
// half asked is a tier that will never complete.
|
|
||||||
func askTier(ctx context.Context, inv *inventory.Inventory, p *inventory.Plan) error {
|
|
||||||
entries, err := inv.Catalogued(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
byName := map[string]inventory.Entry{}
|
|
||||||
for _, e := range entries {
|
|
||||||
byName[e.Manifest.Module] = e
|
|
||||||
}
|
|
||||||
now := time.Now().UTC()
|
|
||||||
for _, name := range p.Tiers[p.Tier] {
|
|
||||||
state := p.Modules[name]
|
|
||||||
if state == nil {
|
|
||||||
state = &inventory.PlanModule{}
|
|
||||||
p.Modules[name] = state
|
|
||||||
}
|
|
||||||
e, known := byName[name]
|
|
||||||
if !known {
|
|
||||||
state.State = "failed"
|
|
||||||
state.Why = "no longer in the catalogue"
|
|
||||||
p.State = inventory.PlanFailed
|
|
||||||
p.Note = name + " is no longer in the catalogue"
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
source := buildSource{Repository: e.Source.Repository, Seat: e.Source.Seat}
|
|
||||||
fmt.Printf(" tier %d: ", p.Tier)
|
|
||||||
if err := buildOne(ctx, source, e.Source.Path, e.Source.Ref, 0); err != nil {
|
|
||||||
state.State = "failed"
|
|
||||||
state.Why = err.Error()
|
|
||||||
p.State = inventory.PlanFailed
|
|
||||||
p.Note = fmt.Sprintf("%s could not be asked for: %v", name, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
state.State = "asked"
|
|
||||||
state.AskedAt = &now
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// planBuilt marks a module built (or failed) in every open plan whose current tier holds it, and
|
|
||||||
// advances what that completes. Called from the daemon's take-in of every outcome.
|
|
||||||
func planBuilt(ctx context.Context, open *stores, module, commit, failed string) {
|
|
||||||
inv := open.inventory
|
|
||||||
plans, err := inv.OpenPlans(ctx)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("plans: cannot read them: %v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
now := time.Now().UTC()
|
|
||||||
for i := range plans {
|
|
||||||
p := &plans[i]
|
|
||||||
if p.Tier >= len(p.Tiers) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
inTier := false
|
|
||||||
for _, m := range p.Tiers[p.Tier] {
|
|
||||||
if m == module {
|
|
||||||
inTier = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !inTier {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
state := p.Modules[module]
|
|
||||||
if state == nil {
|
|
||||||
state = &inventory.PlanModule{}
|
|
||||||
p.Modules[module] = state
|
|
||||||
}
|
|
||||||
if failed != "" {
|
|
||||||
state.State = "failed"
|
|
||||||
state.Why = failed
|
|
||||||
p.State = inventory.PlanFailed
|
|
||||||
p.Note = fmt.Sprintf("%s failed to build in tier %d", module, p.Tier)
|
|
||||||
} else {
|
|
||||||
state.State = "built"
|
|
||||||
state.BuiltAt = &now
|
|
||||||
state.Commit = commit
|
|
||||||
}
|
|
||||||
if err := inv.SavePlan(ctx, *p); err != nil {
|
|
||||||
fmt.Printf("%s: cannot keep the plan: %v\n", p.ID, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if p.State == inventory.PlanFailed {
|
|
||||||
fmt.Printf("%s: %s; the tiers after it are not asked\n", p.ID, p.Note)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
advancePlans(ctx, open)
|
|
||||||
}
|
|
||||||
|
|
||||||
// advancePlans moves every open plan as far as the facts allow: a tier whose modules are all built
|
|
||||||
// and whose gates are applied gives way to the next; the last tier done is the plan done. Called
|
|
||||||
// after every outcome and on a timer, so a plan waiting on a machine's report moves when it comes.
|
|
||||||
func advancePlans(ctx context.Context, open *stores) {
|
|
||||||
inv := open.inventory
|
|
||||||
plans, err := inv.OpenPlans(ctx)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("plans: cannot read them: %v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(plans) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
edges, err := inv.Dependencies(ctx)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("plans: cannot read the dependencies: %v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
rollsOut := func(module string) bool {
|
|
||||||
u, err := inv.UpgradeOf(ctx, module)
|
|
||||||
return err == nil && u.RollOut
|
|
||||||
}
|
|
||||||
for i := range plans {
|
|
||||||
p := &plans[i]
|
|
||||||
for p.Open() {
|
|
||||||
moved, err := advanceOnce(ctx, open, p, edges, rollsOut)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("%s: %v\n", p.ID, err)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if err := inv.SavePlan(ctx, *p); err != nil {
|
|
||||||
fmt.Printf("%s: cannot keep the plan: %v\n", p.ID, err)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if !moved {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// advanceOnce takes one step of one plan and says whether anything changed.
|
|
||||||
func advanceOnce(ctx context.Context, open *stores, p *inventory.Plan,
|
|
||||||
edges []inventory.Edge, rollsOut func(string) bool) (bool, error) {
|
|
||||||
inv := open.inventory
|
|
||||||
if p.Tier >= len(p.Tiers) {
|
|
||||||
p.State = inventory.PlanDone
|
|
||||||
fmt.Printf("%s: done — %s at %s, %d tier(s)\n", p.ID, p.Repository, short(p.Commit), len(p.Tiers))
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
tier := p.Tiers[p.Tier]
|
|
||||||
// Not yet asked: ask.
|
|
||||||
unasked := 0
|
|
||||||
for _, m := range tier {
|
|
||||||
if s := p.Modules[m]; s == nil || s.State == "" {
|
|
||||||
unasked++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if unasked == len(tier) {
|
|
||||||
if err := askTier(ctx, inv, p); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
// Asked: wait for every build.
|
|
||||||
var latest time.Time
|
|
||||||
for _, m := range tier {
|
|
||||||
s := p.Modules[m]
|
|
||||||
if s == nil || s.State != "built" {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
if s.BuiltAt != nil && s.BuiltAt.After(latest) {
|
|
||||||
latest = *s.BuiltAt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Built: send every module of the tier whose policy rolls out, once, to the machines running
|
|
||||||
// it — whether or not its source commit moved. A dependent rebuilt because its base moved, or
|
|
||||||
// a module that packages another repository's source, keeps its commit; the catalogue announces
|
|
||||||
// no move for it and its machines would keep the old image until somebody pushed (novox/hq
|
|
||||||
// issue 189). A module whose policy records is built and left, as its policy says.
|
|
||||||
for _, m := range tier {
|
|
||||||
state := p.Modules[m]
|
|
||||||
if state == nil || state.SentAt != nil || !rollsOut(m) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
running, err := inv.Running(ctx, m)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
now := time.Now().UTC()
|
|
||||||
state.SentAt = &now
|
|
||||||
if len(running) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := sendTo(ctx, open, running); err != nil {
|
|
||||||
return false, fmt.Errorf("sending %s to %s after tier %d: %w", m, strings.Join(running, ", "), p.Tier, err)
|
|
||||||
}
|
|
||||||
fmt.Printf("%s: tier %d built; sent %s to %s\n", p.ID, p.Tier, m, strings.Join(running, ", "))
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
// And wait for what the next tier needs running.
|
|
||||||
needed := gates(*p, edges, rollsOut)
|
|
||||||
if len(needed) > 0 {
|
|
||||||
reports, err := inv.LastReports(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
var waiting []string
|
|
||||||
for _, m := range needed {
|
|
||||||
running, err := inv.Running(ctx, m)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
state := p.Modules[m]
|
|
||||||
if state == nil {
|
|
||||||
state = &inventory.PlanModule{}
|
|
||||||
p.Modules[m] = state
|
|
||||||
}
|
|
||||||
// The plan sends what it waits for. A rebuild from the same source commit is not a
|
|
||||||
// move the catalogue announces — the build machine rebuilt for a controller change
|
|
||||||
// is one — so the roll-out that opens this gate is the plan's to make, once, and
|
|
||||||
// the reports that open it are the ones after the send.
|
|
||||||
since := latest
|
|
||||||
if state.BuiltAt != nil {
|
|
||||||
since = *state.BuiltAt
|
|
||||||
}
|
|
||||||
if state.SentAt != nil && state.SentAt.After(since) {
|
|
||||||
since = *state.SentAt
|
|
||||||
}
|
|
||||||
if ok, on := applied(m, since, running, reports); !ok {
|
|
||||||
waiting = append(waiting, fmt.Sprintf("%s on %s", m, strings.Join(on, ", ")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(waiting) > 0 {
|
|
||||||
note := "tier " + fmt.Sprint(p.Tier) + " built; waiting for " + strings.Join(waiting, "; ") + " to be applied"
|
|
||||||
changed := p.State != inventory.PlanRolling || p.Note != note
|
|
||||||
p.State = inventory.PlanRolling
|
|
||||||
p.Note = note
|
|
||||||
return changed, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
p.Tier++
|
|
||||||
p.State = inventory.PlanBuilding
|
|
||||||
p.Note = ""
|
|
||||||
if p.Tier < len(p.Tiers) {
|
|
||||||
fmt.Printf("%s: tier %d done; asking tier %d: %s\n", p.ID, p.Tier-1, p.Tier, strings.Join(p.Tiers[p.Tier], ", "))
|
|
||||||
}
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// planTicker advances open plans on a timer, for the steps outcomes alone cannot take.
|
|
||||||
func planTicker(ctx context.Context, open *stores) {
|
|
||||||
advancePlans(ctx, open)
|
|
||||||
tick := time.NewTicker(30 * time.Second)
|
|
||||||
defer tick.Stop()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-tick.C:
|
|
||||||
advancePlans(ctx, open)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// planLine is one plan as `status` says it.
|
|
||||||
func planLine(p inventory.Plan, now time.Time) string {
|
|
||||||
where := fmt.Sprintf("tier %d of %d", min(p.Tier+1, len(p.Tiers)), len(p.Tiers))
|
|
||||||
switch p.State {
|
|
||||||
case inventory.PlanDone:
|
|
||||||
return fmt.Sprintf("%s %s done, %d tier(s)", p.Repository, short(p.Commit), len(p.Tiers))
|
|
||||||
case inventory.PlanFailed:
|
|
||||||
return fmt.Sprintf("%s %s FAILED at %s: %s", p.Repository, short(p.Commit), where, p.Note)
|
|
||||||
}
|
|
||||||
since := now.Sub(p.Updated).Round(time.Minute)
|
|
||||||
late := ""
|
|
||||||
if since > planWaitBound {
|
|
||||||
late = " — LATE"
|
|
||||||
}
|
|
||||||
what := "building"
|
|
||||||
if p.State == inventory.PlanRolling {
|
|
||||||
what = p.Note
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%s %s %s, %s for %s%s", p.Repository, short(p.Commit), where, what, since, late)
|
|
||||||
}
|
|
||||||
|
|
||||||
// planFailedBuild marks the module a failed build was for when the result names no module: by the
|
|
||||||
// repository and path the plan's modules were asked at.
|
|
||||||
func planFailedBuild(ctx context.Context, open *stores, result link.BuildResult) {
|
|
||||||
entries, err := open.inventory.Catalogued(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, e := range entries {
|
|
||||||
if repositoryMatches(e.Source.Repository, result.Repository) && e.Source.Path == result.Path {
|
|
||||||
planBuilt(ctx, open, e.Manifest.Module, result.Commit, result.Failed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func repositoryMatches(a, b string) bool {
|
|
||||||
trim := func(s string) string { return strings.ToLower(strings.TrimSuffix(s, ".git")) }
|
|
||||||
return trim(a) == trim(b) || strings.HasSuffix(trim(a), "/"+trim(b)) || strings.HasSuffix(trim(b), "/"+trim(a))
|
|
||||||
}
|
|
||||||
|
|
||||||
// planStatus is one plan as `status --json` says it.
|
|
||||||
type planStatus struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Repository string `json:"repository"`
|
|
||||||
Commit string `json:"commit"`
|
|
||||||
State string `json:"state"`
|
|
||||||
Tier int `json:"tier"`
|
|
||||||
Tiers int `json:"tiers"`
|
|
||||||
Waiting string `json:"waiting,omitempty"`
|
|
||||||
Since time.Time `json:"since"`
|
|
||||||
Late bool `json:"late"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func planStatuses(plans []inventory.Plan, now time.Time) []planStatus {
|
|
||||||
out := make([]planStatus, 0, len(plans))
|
|
||||||
for _, p := range plans {
|
|
||||||
ps := planStatus{ID: p.ID, Repository: p.Repository, Commit: p.Commit, State: p.State,
|
|
||||||
Tier: p.Tier, Tiers: len(p.Tiers), Since: p.Updated}
|
|
||||||
if p.Open() {
|
|
||||||
ps.Waiting = p.Note
|
|
||||||
if ps.Waiting == "" {
|
|
||||||
ps.Waiting = "builds of tier " + fmt.Sprint(p.Tier)
|
|
||||||
}
|
|
||||||
ps.Late = now.Sub(p.Updated) > planWaitBound
|
|
||||||
}
|
|
||||||
out = append(out, ps)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// openPlans is the open plans among the recent ones, and how many have waited past the bound.
|
|
||||||
func openPlans(plans []inventory.Plan) ([]inventory.Plan, int) {
|
|
||||||
var open []inventory.Plan
|
|
||||||
late := 0
|
|
||||||
for _, p := range plans {
|
|
||||||
if p.Open() {
|
|
||||||
open = append(open, p)
|
|
||||||
if time.Since(p.Updated) > planWaitBound {
|
|
||||||
late++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return open, late
|
|
||||||
}
|
|
||||||
|
|
||||||
// plansCommand says what the last merges produced and where each stands; given an id, one plan
|
|
||||||
// tier by tier with every module's state.
|
|
||||||
func plansCommand(ctx context.Context, args []string) error {
|
|
||||||
set := flag.NewFlagSet("plans", flag.ContinueOnError)
|
|
||||||
limit := set.Int("n", 10, "how many to show")
|
|
||||||
whatIf := set.String("what-if", "", "owner/repository: the plan a merge there would produce, saving nothing — with --paths or --modules")
|
|
||||||
paths := set.String("paths", "", "the files the merge would change, comma-separated, from the repository's root")
|
|
||||||
modules := set.String("modules", "", "or the modules it would change, comma-separated")
|
|
||||||
positionals, err := parseAround(set, args)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
open, err := openStores(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer open.Close()
|
|
||||||
inv := open.inventory
|
|
||||||
now := time.Now()
|
|
||||||
if len(positionals) == 1 {
|
|
||||||
p, err := inv.PlanByID(ctx, positionals[0])
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Printf("%s — %s\n", p.ID, planLine(p, now))
|
|
||||||
for i, tier := range p.Tiers {
|
|
||||||
marker := " "
|
|
||||||
if i == p.Tier && p.Open() {
|
|
||||||
marker = ">"
|
|
||||||
}
|
|
||||||
fmt.Printf("%s tier %d\n", marker, i)
|
|
||||||
for _, m := range tier {
|
|
||||||
s := p.Modules[m]
|
|
||||||
state := "not yet asked"
|
|
||||||
if s != nil && s.State != "" {
|
|
||||||
state = s.State
|
|
||||||
if s.Commit != "" {
|
|
||||||
state += " from " + short(s.Commit)
|
|
||||||
}
|
|
||||||
if s.Why != "" {
|
|
||||||
state += ": " + s.Why
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Printf(" %-22s %s\n", m, state)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if *whatIf != "" {
|
|
||||||
return planWhatIf(ctx, inv, *whatIf, splitList(*paths), splitList(*modules))
|
|
||||||
}
|
|
||||||
if len(positionals) == 2 && positionals[0] == "stop" {
|
|
||||||
p, err := inv.PlanByID(ctx, positionals[1])
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !p.Open() {
|
|
||||||
return fmt.Errorf("%s is already %s", p.ID, p.State)
|
|
||||||
}
|
|
||||||
p.State = inventory.PlanFailed
|
|
||||||
p.Note = "stopped by hand at tier " + fmt.Sprint(p.Tier)
|
|
||||||
if err := inv.SavePlan(ctx, p); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Printf("%s stopped at tier %d of %d; what was asked still builds and registers, nothing further is asked\n",
|
|
||||||
p.ID, p.Tier, len(p.Tiers))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
plans, err := inv.RecentPlans(ctx, *limit)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if len(plans) == 0 {
|
|
||||||
fmt.Println("no merge has produced a plan yet")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
for _, p := range plans {
|
|
||||||
fmt.Printf("%-28s %s\n", p.ID, planLine(p, now))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// planWhatIf is the plan a merge would produce, computed the way the merge handler computes one
|
|
||||||
// and saved nowhere: the modules the repository's changed files touch (or the modules named), what
|
|
||||||
// packages their source, everything reachable from them, in tiers. For reading before merging.
|
|
||||||
func planWhatIf(ctx context.Context, inv *inventory.Inventory, repository string, paths, modules []string) error {
|
|
||||||
owner, repo, found := strings.Cut(repository, "/")
|
|
||||||
if !found {
|
|
||||||
return fmt.Errorf("--what-if takes owner/repository, not %q", repository)
|
|
||||||
}
|
|
||||||
m := link.SourceMoved{Owner: owner, Repo: repo, Base: "main", Commit: "what-if", Paths: paths}
|
|
||||||
entries, err := inv.Catalogued(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
read, err := inv.ReadRepositories(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var from, packaging []inventory.Entry
|
|
||||||
named := map[string]bool{}
|
|
||||||
for _, name := range modules {
|
|
||||||
named[name] = true
|
|
||||||
}
|
|
||||||
for _, e := range entries {
|
|
||||||
switch {
|
|
||||||
case named[e.Manifest.Module]:
|
|
||||||
from = append(from, e)
|
|
||||||
case len(named) == 0 && sourceIs(e.Source, m):
|
|
||||||
from = append(from, e)
|
|
||||||
case readsFrom(read[e.Manifest.Module], m):
|
|
||||||
packaging = append(packaging, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(named) == 0 {
|
|
||||||
from = whatTheMergeTouched(from, entries, m)
|
|
||||||
}
|
|
||||||
moved := append(append([]inventory.Entry{}, from...), packaging...)
|
|
||||||
if len(moved) == 0 {
|
|
||||||
fmt.Printf("a merge of %s changing %s would build nothing the mesh holds\n", repository,
|
|
||||||
orNone(strings.Join(append(paths, modules...), ", ")))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
edges, err := inv.Dependencies(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var names []string
|
|
||||||
for _, e := range moved {
|
|
||||||
names = append(names, e.Manifest.Module)
|
|
||||||
}
|
|
||||||
p := planOfMerge(m, names, edges)
|
|
||||||
fmt.Printf("a merge of %s would build %d module(s) in %d tier(s):\n", repository, len(p.Modules), len(p.Tiers))
|
|
||||||
rolls := map[string]string{}
|
|
||||||
for i, tier := range p.Tiers {
|
|
||||||
fmt.Printf(" tier %d\n", i)
|
|
||||||
for _, name := range tier {
|
|
||||||
how := "built; its policy records, so nothing is sent"
|
|
||||||
if u, err := inv.UpgradeOf(ctx, name); err == nil && u.RollOut {
|
|
||||||
running, _ := inv.Running(ctx, name)
|
|
||||||
how = "built, then sent to " + orNone(strings.Join(running, ", "))
|
|
||||||
rolls[name] = how
|
|
||||||
}
|
|
||||||
fmt.Printf(" %-22s %s\n", name, how)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if hasCycle(p.Tiers, edges) {
|
|
||||||
fmt.Println(" the last tier depends on itself and would be built together, in no order")
|
|
||||||
}
|
|
||||||
if len(packaging) > 0 {
|
|
||||||
var also []string
|
|
||||||
for _, e := range packaging {
|
|
||||||
also = append(also, e.Manifest.Module)
|
|
||||||
}
|
|
||||||
fmt.Printf(" %s package source from %s, so they are rebuilt without their own source moving\n",
|
|
||||||
strings.Join(also, ", "), repository)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func splitList(s string) []string {
|
|
||||||
var out []string
|
|
||||||
for _, part := range strings.Split(s, ",") {
|
|
||||||
if part = strings.TrimSpace(part); part != "" {
|
|
||||||
out = append(out, part)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
|
||||||
"github.com/novox/mesh-controller/internal/link"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A merge produces a tiered plan (novox/hq ADR 0162): what moved and everything reachable from it,
|
|
||||||
// sorted so a tier depends only on earlier ones — with the three kinds of dependency told apart.
|
|
||||||
func TestAMergeIsPlannedInTiersAlongTheThreeKindsOfDependency(t *testing.T) {
|
|
||||||
edges := []inventory.Edge{
|
|
||||||
// build dependencies: images on the runtime, a plugin on one of them
|
|
||||||
{From: "shop", To: "mesh-tools", Kind: inventory.EdgeStandsOn},
|
|
||||||
{From: "postgres", To: "mesh-tools", Kind: inventory.EdgeStandsOn},
|
|
||||||
{From: "shop-plugin", To: "shop", Kind: inventory.EdgeDeclared},
|
|
||||||
// a code dependency: the proxy packages the controller's source — same tier
|
|
||||||
{From: "route-proxy", To: "mesh-controller", Kind: inventory.EdgePackages},
|
|
||||||
{From: "builder", To: "mesh-controller", Kind: inventory.EdgePackages},
|
|
||||||
// runtime dependencies: everything source-built is built by the builder
|
|
||||||
{From: "shop", To: "builder", Kind: inventory.EdgeBuiltBy},
|
|
||||||
{From: "postgres", To: "builder", Kind: inventory.EdgeBuiltBy},
|
|
||||||
{From: "shop-plugin", To: "builder", Kind: inventory.EdgeBuiltBy},
|
|
||||||
{From: "route-proxy", To: "builder", Kind: inventory.EdgeBuiltBy},
|
|
||||||
{From: "mesh-controller", To: "builder", Kind: inventory.EdgeBuiltBy},
|
|
||||||
{From: "mesh-tools", To: "builder", Kind: inventory.EdgeBuiltBy},
|
|
||||||
{From: "builder", To: "mesh-tools", Kind: inventory.EdgeStandsOn},
|
|
||||||
{From: "unrelated", To: "alpine", Kind: inventory.EdgeStandsOn},
|
|
||||||
}
|
|
||||||
// The runtime image moved: everything on it, and what is built by what is on it.
|
|
||||||
set := reachableFrom([]string{"mesh-tools"}, edges)
|
|
||||||
// What stands on the runtime, and the builder that stands on it; not the controller, which the
|
|
||||||
// builder merely builds, nor the proxy that packages the controller.
|
|
||||||
want := []string{"builder", "mesh-tools", "postgres", "shop", "shop-plugin"}
|
|
||||||
if len(set) != len(want) {
|
|
||||||
t.Fatalf("reachable from the runtime: %v, want %v", set, want)
|
|
||||||
}
|
|
||||||
tiers := tiersOf(set, edges)
|
|
||||||
pos := map[string]int{}
|
|
||||||
for i, tier := range tiers {
|
|
||||||
for _, m := range tier {
|
|
||||||
pos[m] = i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if pos["mesh-tools"] != 0 || pos["builder"] != 1 {
|
|
||||||
t.Fatalf("the runtime then the builder: %v", tiers)
|
|
||||||
}
|
|
||||||
if !(pos["shop"] > pos["builder"] && pos["postgres"] > pos["builder"]) {
|
|
||||||
t.Fatalf("what the builder builds comes after the builder: %v", tiers)
|
|
||||||
}
|
|
||||||
if pos["shop-plugin"] <= pos["shop"] {
|
|
||||||
t.Fatalf("a plugin after what it is declared on: %v", tiers)
|
|
||||||
}
|
|
||||||
if hasCycle(tiers, edges) {
|
|
||||||
t.Fatalf("no cycle here: %v", tiers)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The controller alone moved: the proxy with it, nothing else.
|
|
||||||
small := reachableFrom([]string{"mesh-controller"}, edges)
|
|
||||||
if len(small) != 3 {
|
|
||||||
t.Fatalf("a controller merge rebuilds the controller and what packages it: %v", small)
|
|
||||||
}
|
|
||||||
// The builder packages the controller's source (same tier by that edge) and the controller is
|
|
||||||
// built by the builder (next tier by that one): the builder first, then the controller and the
|
|
||||||
// proxy together — a code dependency in one tier, a runtime dependency across tiers.
|
|
||||||
smallTiers := tiersOf(small, edges)
|
|
||||||
if len(smallTiers) != 2 || smallTiers[0][0] != "builder" || len(smallTiers[1]) != 2 {
|
|
||||||
t.Fatalf("the builder, then the controller and the proxy together: %v", smallTiers)
|
|
||||||
}
|
|
||||||
// The builder alone moved: the builder, and nothing it builds.
|
|
||||||
if only := reachableFrom([]string{"builder"}, edges); len(only) != 1 {
|
|
||||||
t.Fatalf("a build machine change rebuilds the build machine alone: %v", only)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only a runtime dependency gates on deployment, and only when the module rolls out.
|
|
||||||
p := planOfMerge(link.SourceMoved{Owner: "novox", Repo: "mesh-tools", Commit: "abc"}, []string{"mesh-tools"}, edges)
|
|
||||||
p.Tier = pos["builder"]
|
|
||||||
rollsOut := func(m string) bool { return m == "builder" }
|
|
||||||
if g := gates(p, edges, rollsOut); len(g) != 1 || g[0] != "builder" {
|
|
||||||
t.Fatalf("the builder gates the tier after it: %v", g)
|
|
||||||
}
|
|
||||||
p.Tier = 0
|
|
||||||
if g := gates(p, edges, rollsOut); len(g) != 0 {
|
|
||||||
t.Fatalf("the runtime image is a build dependency and gates nothing: %v", g)
|
|
||||||
}
|
|
||||||
if g := gates(p, edges, func(string) bool { return false }); len(g) != 0 {
|
|
||||||
t.Fatalf("a module that only records its upgrade gates nothing: %v", g)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A gate is open once every machine running the module has reported after it was built.
|
|
||||||
func TestAGateOpensWhenTheMachinesHaveReportedSinceTheBuild(t *testing.T) {
|
|
||||||
built := time.Date(2026, 10, 1, 15, 0, 0, 0, time.UTC)
|
|
||||||
before, after := built.Add(-time.Minute), built.Add(time.Minute)
|
|
||||||
reports := []inventory.Reported{{Node: "anchor", At: &after}, {Node: "home-server", At: &before}}
|
|
||||||
ok, waiting := applied("builder", built, []string{"anchor", "home-server"}, reports)
|
|
||||||
if ok || len(waiting) != 1 || waiting[0] != "home-server" {
|
|
||||||
t.Fatalf("one machine has not reported since the build: ok=%v waiting=%v", ok, waiting)
|
|
||||||
}
|
|
||||||
if ok, _ := applied("builder", built, []string{"anchor"}, reports); !ok {
|
|
||||||
t.Fatal("the machine that reported after the build holds the gate open")
|
|
||||||
}
|
|
||||||
if ok, _ := applied("builder", built, nil, reports); !ok {
|
|
||||||
t.Fatal("a module running nowhere gates nothing")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A cycle is not lost: what remains is one last tier, and the caller says so.
|
|
||||||
func TestACycleIsOneLastTierAndSaidSo(t *testing.T) {
|
|
||||||
edges := []inventory.Edge{{From: "a", To: "b", Kind: inventory.EdgeStandsOn}, {From: "b", To: "a", Kind: inventory.EdgeStandsOn}}
|
|
||||||
tiers := tiersOf([]string{"a", "b"}, edges)
|
|
||||||
if len(tiers) != 1 || len(tiers[0]) != 2 || !hasCycle(tiers, edges) {
|
|
||||||
t.Fatalf("a cycle should be one tier of two, said: %v", tiers)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -48,21 +48,6 @@ func argvFor(verb string, args map[string]any) ([]string, error) {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
switch verb {
|
switch verb {
|
||||||
case "command":
|
|
||||||
// The generic verb: the command line as given, split as a shell would split it, with
|
|
||||||
// nothing added — the named verbs add flags a caller cannot reach; this one is the whole
|
|
||||||
// binary and says so in its description (novox/hq ADR 0154, 0175).
|
|
||||||
if err := need("command"); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
argv, err := splitCommandLine(str("command"))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if len(argv) == 0 {
|
|
||||||
return nil, errors.New("command names no command")
|
|
||||||
}
|
|
||||||
return argv, nil
|
|
||||||
case "status":
|
case "status":
|
||||||
return []string{"status", "--json"}, nil
|
return []string{"status", "--json"}, nil
|
||||||
case "nodes":
|
case "nodes":
|
||||||
@@ -84,24 +69,6 @@ func argvFor(verb string, args map[string]any) ([]string, error) {
|
|||||||
return []string{"builds", m}, nil
|
return []string{"builds", m}, nil
|
||||||
}
|
}
|
||||||
return []string{"builds"}, nil
|
return []string{"builds"}, nil
|
||||||
case "plans":
|
|
||||||
if r := str("repository"); r != "" {
|
|
||||||
argv := []string{"plans", "--what-if", r}
|
|
||||||
if p := str("paths"); p != "" {
|
|
||||||
argv = append(argv, "--paths", p)
|
|
||||||
}
|
|
||||||
if m := str("modules"); m != "" {
|
|
||||||
argv = append(argv, "--modules", m)
|
|
||||||
}
|
|
||||||
return argv, nil
|
|
||||||
}
|
|
||||||
if id := str("stop"); id != "" {
|
|
||||||
return []string{"plans", "stop", id}, nil
|
|
||||||
}
|
|
||||||
if id := str("id"); id != "" {
|
|
||||||
return []string{"plans", id}, nil
|
|
||||||
}
|
|
||||||
return []string{"plans"}, nil
|
|
||||||
case "plan":
|
case "plan":
|
||||||
if err := need("node"); err != nil {
|
if err := need("node"); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -112,16 +79,6 @@ func argvFor(verb string, args map[string]any) ([]string, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return []string{verb, str("node"), str("module")}, nil
|
return []string{verb, str("node"), str("module")}, nil
|
||||||
case "pin":
|
|
||||||
if err := need("node", "provision", "from", "module"); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return []string{"pin", str("node"), str("provision"), str("from"), str("module")}, nil
|
|
||||||
case "unpin":
|
|
||||||
if err := need("node", "provision"); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return []string{"unpin", str("node"), str("provision")}, nil
|
|
||||||
case "push":
|
case "push":
|
||||||
// Sent and not waited for: the asker reads `status` for what the machine did, which is
|
// Sent and not waited for: the asker reads `status` for what the machine did, which is
|
||||||
// what a person at a shell does too. A tool call that blocked for a push's whole apply would
|
// what a person at a shell does too. A tool call that blocked for a push's whole apply would
|
||||||
@@ -144,54 +101,16 @@ func argvFor(verb string, args map[string]any) ([]string, error) {
|
|||||||
// Half of either shape: the command says its usage, which names both shapes, and that is
|
// Half of either shape: the command says its usage, which names both shapes, and that is
|
||||||
// the answer the caller needs.
|
// the answer the caller needs.
|
||||||
return []string{"rotate"}, nil
|
return []string{"rotate"}, nil
|
||||||
case "token":
|
|
||||||
// `token issue` at a shell (novox/hq ADR 0169). Exactly one of node or new; the command
|
|
||||||
// refuses both or neither in its own words.
|
|
||||||
argv := []string{"token", "issue"}
|
|
||||||
if n := str("node"); n != "" {
|
|
||||||
argv = append(argv, "--node", n)
|
|
||||||
}
|
|
||||||
if n := str("new"); n != "" {
|
|
||||||
argv = append(argv, "--new", n)
|
|
||||||
}
|
|
||||||
if k := str("overlay_key"); k != "" {
|
|
||||||
argv = append(argv, "--overlay-key", k)
|
|
||||||
}
|
|
||||||
if d := str("for"); d != "" {
|
|
||||||
argv = append(argv, "--for", d)
|
|
||||||
}
|
|
||||||
if str("adopted") == "true" {
|
|
||||||
argv = append(argv, "--adopted")
|
|
||||||
}
|
|
||||||
return argv, nil
|
|
||||||
case "settings":
|
|
||||||
// `settings set|clear` at a shell (novox/hq issue 198). The values travel as an argument
|
|
||||||
// because a tool has no file to hand the command; the command reads either.
|
|
||||||
if err := need("module"); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
argv := []string{"settings", "set", str("module")}
|
|
||||||
switch {
|
|
||||||
case str("clear") == "true":
|
|
||||||
argv = []string{"settings", "clear", str("module")}
|
|
||||||
case str("values") != "":
|
|
||||||
argv = append(argv, str("values"))
|
|
||||||
}
|
|
||||||
// Neither values nor clear: the command says its usage, which names both, and that is the
|
|
||||||
// answer the caller needs — the same as `rotate` given half of either shape.
|
|
||||||
if n := str("node"); n != "" {
|
|
||||||
argv = append(argv, "--node", n)
|
|
||||||
}
|
|
||||||
return argv, nil
|
|
||||||
case "issue":
|
|
||||||
// The same act as `module issue` at a shell (novox/hq design 25 §4): the account is minted
|
|
||||||
// into the mesh's records and delivered at the machine's next push, which is the caller's to
|
|
||||||
// ask for — so the mesh is never pushed as a side effect of a credential.
|
|
||||||
if err := need("node", "module"); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return []string{"module", "issue", str("module"), "--node", str("node")}, nil
|
|
||||||
case "build":
|
case "build":
|
||||||
|
// Three shapes, as the command has them: a repository, a base every module built on it
|
||||||
|
// is rebuilt from (`--on`), or everything behind its source (`--behind`). Asked, not
|
||||||
|
// waited for, the same as a single build.
|
||||||
|
if on := str("on"); on != "" {
|
||||||
|
return []string{"build", "--on", on, "--wait", "0"}, nil
|
||||||
|
}
|
||||||
|
if b := str("behind"); b != "" && b != "no" && b != "false" {
|
||||||
|
return []string{"build", "--behind", "--wait", "0"}, nil
|
||||||
|
}
|
||||||
if err := need("repository"); err != nil {
|
if err := need("repository"); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -266,7 +185,7 @@ func seatToolHandlers() (map[string]link.ToolHandler, error) {
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, err := argvFor(verb, sampleArguments(v)); err != nil {
|
if _, err := argvFor(verb, map[string]any{"node": "x", "module": "x", "repository": "x"}); err != nil {
|
||||||
return nil, fmt.Errorf("the %s seat's row declares %q, which this control plane cannot run: %w",
|
return nil, fmt.Errorf("the %s seat's row declares %q, which this control plane cannot run: %w",
|
||||||
catalogue.ControllerSeatName, verb, err)
|
catalogue.ControllerSeatName, verb, err)
|
||||||
}
|
}
|
||||||
@@ -305,76 +224,3 @@ func seatTools() map[string]any {
|
|||||||
}
|
}
|
||||||
return map[string]any{"seats": seats}
|
return map[string]any{"seats": seats}
|
||||||
}
|
}
|
||||||
|
|
||||||
// sampleArguments is one of every argument a verb's schema requires, so the check at start proves the
|
|
||||||
// verb runnable rather than that it happens to want the arguments the check guessed.
|
|
||||||
func sampleArguments(v catalogue.Verb) map[string]any {
|
|
||||||
sample := map[string]any{"node": "x", "module": "x", "repository": "x"}
|
|
||||||
switch required := v.Input["required"].(type) {
|
|
||||||
case []string:
|
|
||||||
for _, k := range required {
|
|
||||||
sample[k] = "x"
|
|
||||||
}
|
|
||||||
case []any:
|
|
||||||
for _, k := range required {
|
|
||||||
if name, ok := k.(string); ok {
|
|
||||||
sample[name] = "x"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sample
|
|
||||||
}
|
|
||||||
|
|
||||||
// splitCommandLine splits a command line into words the way a POSIX shell does for the simple
|
|
||||||
// cases a controller command needs: spaces separate, single or double quotes group, a backslash
|
|
||||||
// escapes the next character inside double quotes or outside any. No expansion of anything.
|
|
||||||
func splitCommandLine(line string) ([]string, error) {
|
|
||||||
var words []string
|
|
||||||
var cur strings.Builder
|
|
||||||
inWord := false
|
|
||||||
quote := rune(0)
|
|
||||||
runes := []rune(line)
|
|
||||||
for i := 0; i < len(runes); i++ {
|
|
||||||
r := runes[i]
|
|
||||||
switch {
|
|
||||||
case quote == '\'':
|
|
||||||
if r == '\'' {
|
|
||||||
quote = 0
|
|
||||||
} else {
|
|
||||||
cur.WriteRune(r)
|
|
||||||
}
|
|
||||||
case quote == '"':
|
|
||||||
if r == '"' {
|
|
||||||
quote = 0
|
|
||||||
} else if r == '\\' && i+1 < len(runes) {
|
|
||||||
i++
|
|
||||||
cur.WriteRune(runes[i])
|
|
||||||
} else {
|
|
||||||
cur.WriteRune(r)
|
|
||||||
}
|
|
||||||
case r == '\'' || r == '"':
|
|
||||||
quote = r
|
|
||||||
inWord = true
|
|
||||||
case r == '\\' && i+1 < len(runes):
|
|
||||||
i++
|
|
||||||
cur.WriteRune(runes[i])
|
|
||||||
inWord = true
|
|
||||||
case r == ' ' || r == '\t' || r == '\n':
|
|
||||||
if inWord {
|
|
||||||
words = append(words, cur.String())
|
|
||||||
cur.Reset()
|
|
||||||
inWord = false
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
cur.WriteRune(r)
|
|
||||||
inWord = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if quote != 0 {
|
|
||||||
return nil, fmt.Errorf("command has an unclosed %c quote", quote)
|
|
||||||
}
|
|
||||||
if inWord {
|
|
||||||
words = append(words, cur.String())
|
|
||||||
}
|
|
||||||
return words, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -73,46 +73,6 @@ func TestRotateTakesAProvisionOrAnOwnSecret(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// `token` is `token issue` at a shell, with the machine's tunnel key (novox/hq ADR 0169).
|
|
||||||
func TestTokenIssuesForAMachineAndItsTunnelKey(t *testing.T) {
|
|
||||||
argv, err := argvFor("token", map[string]any{"new": "laptop", "overlay_key": "k", "for": "2h"})
|
|
||||||
if err != nil || strings.Join(argv, " ") != "token issue --new laptop --overlay-key k --for 2h" {
|
|
||||||
t.Fatalf("token: %v %v", argv, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// `settings` is `settings set|clear` at a shell, with the values passed inline (novox/hq issue 198).
|
|
||||||
func TestSettingsSetsOrClearsALayer(t *testing.T) {
|
|
||||||
argv, err := argvFor("settings", map[string]any{"module": "dnsmasq", "values": `{"a":1}`, "node": "ace"})
|
|
||||||
if err != nil || strings.Join(argv, " ") != `settings set dnsmasq {"a":1} --node ace` {
|
|
||||||
t.Fatalf("set on a machine: %v %v", argv, err)
|
|
||||||
}
|
|
||||||
argv, _ = argvFor("settings", map[string]any{"module": "dnsmasq", "clear": "true"})
|
|
||||||
if strings.Join(argv, " ") != "settings clear dnsmasq" {
|
|
||||||
t.Fatalf("clear for the mesh: %v", argv)
|
|
||||||
}
|
|
||||||
argv, _ = argvFor("settings", map[string]any{"module": "dnsmasq"})
|
|
||||||
if strings.Join(argv, " ") != "settings set dnsmasq" {
|
|
||||||
t.Fatalf("a set with no values falls to the command's usage: %v", argv)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// `issue` is `module issue` at a shell: the module and the machine, and nothing that would push. A
|
|
||||||
// module's bus account was mintable only from the controller's command line, so an agent working
|
|
||||||
// through the tools could not finish a rollout that gave a module one (novox/hq issue 191).
|
|
||||||
func TestIssueGivesAModuleItsAccountOnAMachine(t *testing.T) {
|
|
||||||
argv, err := argvFor("issue", map[string]any{"node": "ace", "module": "route-proxy"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if strings.Join(argv, " ") != "module issue route-proxy --node ace" {
|
|
||||||
t.Fatalf("issue runs %v", argv)
|
|
||||||
}
|
|
||||||
if _, err := argvFor("issue", map[string]any{"module": "route-proxy"}); err == nil {
|
|
||||||
t.Error("an account was issued without saying which machine reads it")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A required argument missing is refused in the verb's own words, before anything runs.
|
// A required argument missing is refused in the verb's own words, before anything runs.
|
||||||
func TestAVerbMissingWhatItNeedsIsRefused(t *testing.T) {
|
func TestAVerbMissingWhatItNeedsIsRefused(t *testing.T) {
|
||||||
if _, err := argvFor("node", map[string]any{}); err == nil || !strings.Contains(err.Error(), `node needs "node"`) {
|
if _, err := argvFor("node", map[string]any{}); err == nil || !strings.Contains(err.Error(), `node needs "node"`) {
|
||||||
@@ -180,26 +140,18 @@ func TestAJSONVerbsAnswerIsItsStandardOutput(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// `command` is the generic verb: the command line as given, split as a shell would, nothing added —
|
// The build tool has the command's three shapes (ADR 0157's follow-up, 2026-10-01): a repository, a
|
||||||
// so an operator's `node account g14 jochen` is one call through the console rather than a shell on
|
// base whose dependents are rebuilt, or everything behind its source — each asked, not waited for.
|
||||||
// the control node (novox/hq ADR 0154, ADR 0175).
|
func TestTheBuildToolRebuildsWhatStandsOnABase(t *testing.T) {
|
||||||
func TestCommandRunsTheLineAsGiven(t *testing.T) {
|
argv, _ := argvFor("build", map[string]any{"on": "mesh-tools"})
|
||||||
argv, err := argvFor("command", map[string]any{"command": "node account g14 jochen"})
|
if strings.Join(argv, " ") != "build --on mesh-tools --wait 0" {
|
||||||
if err != nil || strings.Join(argv, " ") != "node account g14 jochen" {
|
t.Fatalf("a base: %v", argv)
|
||||||
t.Fatalf("a plain line: %v %v", argv, err)
|
|
||||||
}
|
}
|
||||||
argv, err = argvFor("command", map[string]any{"command": `settings set dnsmasq '{"a": "b c"}' --node ace`})
|
argv, _ = argvFor("build", map[string]any{"behind": "yes"})
|
||||||
if err != nil || len(argv) != 6 || argv[3] != `{"a": "b c"}` {
|
if strings.Join(argv, " ") != "build --behind --wait 0" {
|
||||||
t.Fatalf("a quoted word stays one word: %q %v", argv, err)
|
t.Fatalf("behind: %v", argv)
|
||||||
}
|
}
|
||||||
argv, err = argvFor("command", map[string]any{"command": `node add "the box" --adopted`})
|
if _, err := argvFor("build", map[string]any{}); err == nil {
|
||||||
if err != nil || len(argv) != 4 || argv[2] != "the box" {
|
t.Fatal("a build naming nothing was accepted")
|
||||||
t.Fatalf("double quotes group: %q %v", argv, err)
|
|
||||||
}
|
|
||||||
if _, err := argvFor("command", map[string]any{"command": " "}); err == nil {
|
|
||||||
t.Fatal("an empty line was accepted")
|
|
||||||
}
|
|
||||||
if _, err := argvFor("command", map[string]any{"command": `node "unclosed`}); err == nil {
|
|
||||||
t.Fatal("an unclosed quote was accepted")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,20 +25,6 @@ type sendable struct {
|
|||||||
// Adoption is nil for a converged node, and then the body is byte for byte what it was before
|
// Adoption is nil for a converged node, and then the body is byte for byte what it was before
|
||||||
// adoption existed: an older host parses the envelope strictly and would refuse the key.
|
// adoption existed: an older host parses the envelope strictly and would refuse the key.
|
||||||
Adoption *adoptionEnvelope
|
Adoption *adoptionEnvelope
|
||||||
|
|
||||||
// Received and Mesh are not sent in the declaration. They are what this machine's memberships
|
|
||||||
// are issued with on the bus (novox/hq ADR 0167): each module's received contributions, from
|
|
||||||
// the same composition as its received files, and every machine's private-network address.
|
|
||||||
Received map[string]map[string][]catalogue.Contribution
|
|
||||||
Mesh []string
|
|
||||||
// LeftOut is every module of the machine's set left out of this declaration because a stored
|
|
||||||
// setting cannot compose with its definition (novox/hq ADR 0163, rule 6), sorted. The host
|
|
||||||
// keeps that module's held things and touches none of its containers; a machine is told
|
|
||||||
// everything or nothing about what it IS told, and what it is not told is said. Absent from
|
|
||||||
// the body when empty, so a declaration that leaves nothing out is byte for byte what it was.
|
|
||||||
LeftOut []string
|
|
||||||
// leftOutWhy is why each was, for push and plan to say; never on the wire.
|
|
||||||
leftOutWhy map[string]string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// adoptionEnvelope is what an adopted node is told about its mode. Taken is every module taken on
|
// adoptionEnvelope is what an adopted node is told about its mode. Taken is every module taken on
|
||||||
@@ -59,9 +45,6 @@ func (s sendable) Body() ([]byte, error) {
|
|||||||
if s.Sequence > 0 {
|
if s.Sequence > 0 {
|
||||||
envelope["sequence"] = s.Sequence
|
envelope["sequence"] = s.Sequence
|
||||||
}
|
}
|
||||||
if len(s.LeftOut) > 0 {
|
|
||||||
envelope["left_out"] = s.LeftOut
|
|
||||||
}
|
|
||||||
// An empty declaration is deliberate here — the node owns nothing the mesh put there
|
// An empty declaration is deliberate here — the node owns nothing the mesh put there
|
||||||
// (novox/hq issue 127) — and the host refuses an empty body unless it is told the emptiness
|
// (novox/hq issue 127) — and the host refuses an empty body unless it is told the emptiness
|
||||||
// is meant, so a truncated or mis-composed body is never mistaken for "own nothing".
|
// is meant, so a truncated or mis-composed body is never mistaken for "own nothing".
|
||||||
|
|||||||
@@ -382,62 +382,3 @@ func TestAnEmptyDeclarationSaysOwnsNothing(t *testing.T) {
|
|||||||
t.Fatalf("a non-empty declaration must not mark owns_nothing; got %v", env)
|
t.Fatalf("a non-empty declaration must not mark owns_nothing; got %v", env)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A setting is judged where it is stored, and an impossible one costs a module, not a machine
|
|
||||||
// (novox/hq ADR 0163, rule 6): stored while it composed, a setting whose definition then moved from
|
|
||||||
// under it leaves that module out of the declaration — said in the envelope, so the host keeps the
|
|
||||||
// module's things — and the machine is told everything else.
|
|
||||||
func TestADefinitionMovingUnderAStoredSettingLeavesThatModuleOutNotTheMachine(t *testing.T) {
|
|
||||||
open := aMesh(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
web := helloWeb()
|
|
||||||
web.Resources[1]["ports"] = []any{"8080"}
|
|
||||||
register(t, open, web)
|
|
||||||
register(t, open, catalogue.Manifest{Module: "notes", Version: "1",
|
|
||||||
Resources: []map[string]any{{"id": "conf", "type": "file", "path": "/etc/notes.conf", "content": "x"}}})
|
|
||||||
for _, m := range []string{"hello-web", "notes"} {
|
|
||||||
if _, err := assign(ctx, open, "laptop", m); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Judged where it is stored: a port the module does not publish is refused by name.
|
|
||||||
err := open.inventory.SetSettings(ctx, "laptop", "hello-web",
|
|
||||||
map[string]any{catalogue.PortsSetting: map[string]any{"9999": 10000}})
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "hello-web on laptop") || !strings.Contains(err.Error(), "9999") {
|
|
||||||
t.Fatalf("an impossible setting was stored: %v", err)
|
|
||||||
}
|
|
||||||
if err := open.inventory.SetSettings(ctx, "laptop", "hello-web",
|
|
||||||
map[string]any{catalogue.PortsSetting: map[string]any{"8080": 10000}}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if declared := composed(t, open, "laptop"); len(declared.LeftOut) != 0 {
|
|
||||||
t.Fatalf("a setting that composes left a module out: %v", declared.LeftOut)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The definition moves: the container publishes another port now.
|
|
||||||
web.Version = "2"
|
|
||||||
web.Resources[1]["ports"] = []any{"9090"}
|
|
||||||
register(t, open, web)
|
|
||||||
declared := composed(t, open, "laptop")
|
|
||||||
if len(declared.LeftOut) != 1 || declared.LeftOut[0] != "hello-web" {
|
|
||||||
t.Fatalf("hello-web is not left out: %v", declared.LeftOut)
|
|
||||||
}
|
|
||||||
if !strings.Contains(declared.leftOutWhy["hello-web"], "no container of its publishes 8080") {
|
|
||||||
t.Fatalf("why it was left out is not said: %v", declared.leftOutWhy)
|
|
||||||
}
|
|
||||||
if hasID(declared.Resources, "hello-web.server") || !hasID(declared.Resources, "notes.conf") {
|
|
||||||
t.Fatalf("the machine was not told everything else: %v", declared.Resources)
|
|
||||||
}
|
|
||||||
body, err := declared.Body()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var env map[string]any
|
|
||||||
if err := json.Unmarshal(body, &env); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
left, _ := env["left_out"].([]any)
|
|
||||||
if len(left) != 1 || left[0] != "hello-web" {
|
|
||||||
t.Fatalf("the envelope does not say what was left out: %v", env)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -138,18 +138,6 @@ func printStatus(asked answers) error {
|
|||||||
len(quiet), strings.Join(said, "\n "))
|
len(quiet), strings.Join(said, "\n "))
|
||||||
}
|
}
|
||||||
|
|
||||||
if open, late := openPlans(asked.plans); len(open) > 0 {
|
|
||||||
fmt.Printf("%d plan(s) open", len(open))
|
|
||||||
if late > 0 {
|
|
||||||
fmt.Printf(", %d waiting past %s", late, planWaitBound)
|
|
||||||
}
|
|
||||||
fmt.Println(":")
|
|
||||||
for _, p := range open {
|
|
||||||
fmt.Printf(" %s\n", planLine(p, time.Now()))
|
|
||||||
}
|
|
||||||
fmt.Println()
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(behind) > 0 {
|
if len(behind) > 0 {
|
||||||
var names []string
|
var names []string
|
||||||
for m := range behind {
|
for m := range behind {
|
||||||
@@ -227,28 +215,6 @@ func printStatus(asked answers) error {
|
|||||||
" not readable from a commit; that needs a version the host reports as ordered\n\n")
|
" not readable from a commit; that needs a version the host reports as ordered\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(asked.filtered) > 0 {
|
|
||||||
// A converged machine is filtered by the mesh alone, and the mesh says truthfully which
|
|
||||||
// (novox/hq ADR 0168). One that is not — a predecessor's chain still refusing, a found
|
|
||||||
// firewall in force again — is said here, and is not well.
|
|
||||||
machines := make([]string, 0, len(asked.filtered))
|
|
||||||
for name := range asked.filtered {
|
|
||||||
machines = append(machines, name)
|
|
||||||
}
|
|
||||||
sort.Strings(machines)
|
|
||||||
fmt.Printf("%d converged machine(s) are not filtered by the mesh alone:\n", len(machines))
|
|
||||||
for _, name := range machines {
|
|
||||||
f := asked.filtered[name]
|
|
||||||
if fw := f.FoundFirewall; fw != nil && fw.Active {
|
|
||||||
fmt.Printf(" %-12s the found firewall (%s) is in force again; the next apply retires it\n", name, fw.Kind)
|
|
||||||
}
|
|
||||||
for _, x := range f.Others() {
|
|
||||||
fmt.Printf(" %-12s %s (%s): %s\n", name, x.Where, x.Owner, x.Refuses)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Printf("\n the mesh wrote none of these and removes none; `node show <node>` lists every filter with its owner\n\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(asked.untaken) > 0 {
|
if len(asked.untaken) > 0 {
|
||||||
// **Before the adopted line, and it breaks "all well".** An adopted machine is a state
|
// **Before the adopted line, and it breaks "all well".** An adopted machine is a state
|
||||||
// somebody chose and can leave alone; a module assigned to one and never taken is work
|
// somebody chose and can leave alone; a module assigned to one and never taken is work
|
||||||
@@ -323,10 +289,7 @@ func firstLine(s string) string {
|
|||||||
// A type of its own rather than a method on the enrolment, because they are unrelated things
|
// A type of its own rather than a method on the enrolment, because they are unrelated things
|
||||||
// arriving on one queue and an implementation of one should not have to say anything about the
|
// arriving on one queue and an implementation of one should not have to say anything about the
|
||||||
// other.
|
// other.
|
||||||
type builds struct {
|
type builds struct{ inv *inventory.Inventory }
|
||||||
inv *inventory.Inventory
|
|
||||||
open *stores
|
|
||||||
}
|
|
||||||
|
|
||||||
// theThreeQuestions reads what anything answering "is the mesh alright" needs.
|
// theThreeQuestions reads what anything answering "is the mesh alright" needs.
|
||||||
//
|
//
|
||||||
@@ -379,17 +342,6 @@ func theThreeQuestions(ctx context.Context, open *stores) (answers, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return answers{}, err
|
return answers{}, err
|
||||||
}
|
}
|
||||||
// And which converged machines something other than the mesh filters (novox/hq ADR 0168), as
|
|
||||||
// each last reported — the account that was missing when a predecessor's chain refused what the
|
|
||||||
// mesh declared open for eleven hours (04-ISSUES/144, 145).
|
|
||||||
out.filtered, err = filteredMachines(ctx, inv, out.nodes)
|
|
||||||
if err != nil {
|
|
||||||
return answers{}, err
|
|
||||||
}
|
|
||||||
out.plans, err = inv.RecentPlans(ctx, 5)
|
|
||||||
if err != nil {
|
|
||||||
return answers{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// And which machines are not running what the mesh would send them. The same question as a
|
// And which machines are not running what the mesh would send them. The same question as a
|
||||||
// module being behind its source, one level down: that one says the catalogue is out of date,
|
// module being behind its source, one level down: that one says the catalogue is out of date,
|
||||||
@@ -435,30 +387,6 @@ func theThreeQuestions(ctx context.Context, open *stores) (answers, error) {
|
|||||||
//
|
//
|
||||||
// A machine that reports no holds contributes nothing, so a converged mesh answers an empty map and
|
// A machine that reports no holds contributes nothing, so a converged mesh answers an empty map and
|
||||||
// the caller prints nothing.
|
// the caller prints nothing.
|
||||||
// filteredMachines is every converged machine not filtered by the mesh alone, with what it last said
|
|
||||||
// filters it (novox/hq ADR 0168). An adopted machine keeps its found firewall by design and is not
|
|
||||||
// counted; a machine that has not said is not said to be filtered by anything.
|
|
||||||
func filteredMachines(ctx context.Context, inv *inventory.Inventory, nodes []inventory.Node) (
|
|
||||||
map[string]inventory.Filtering, error) {
|
|
||||||
out := map[string]inventory.Filtering{}
|
|
||||||
for _, n := range nodes {
|
|
||||||
if n.Adopted {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
f, err := inv.FilteringOf(ctx, n.Name)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("what filters %s cannot be read: %w", n.Name, err)
|
|
||||||
}
|
|
||||||
if len(f.Filters) == 0 && f.FoundFirewall == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !f.Alone() {
|
|
||||||
out[n.Name] = f
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func untakenModules(ctx context.Context, inv *inventory.Inventory, nodes []inventory.Node) (
|
func untakenModules(ctx context.Context, inv *inventory.Inventory, nodes []inventory.Node) (
|
||||||
map[string]map[string]int, error) {
|
map[string]map[string]int, error) {
|
||||||
|
|
||||||
@@ -495,8 +423,7 @@ func untakenModules(ctx context.Context, inv *inventory.Inventory, nodes []inven
|
|||||||
// read as success for the whole of the edge cut-over outage (novox/hq 04-ISSUES/125).
|
// read as success for the whole of the edge cut-over outage (novox/hq 04-ISSUES/125).
|
||||||
func (a answers) well() bool {
|
func (a answers) well() bool {
|
||||||
return len(a.wrong) == 0 && len(a.quiet) == 0 && len(a.behind) == 0 &&
|
return len(a.wrong) == 0 && len(a.quiet) == 0 && len(a.behind) == 0 &&
|
||||||
len(a.waiting) == 0 && len(a.refused) == 0 && a.network == "" && len(a.untaken) == 0 &&
|
len(a.waiting) == 0 && len(a.refused) == 0 && a.network == "" && len(a.untaken) == 0
|
||||||
len(a.filtered) == 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// hostSplit is which machines report which host version, for every version more than one machine
|
// hostSplit is which machines report which host version, for every version more than one machine
|
||||||
|
|||||||
@@ -1,143 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
|
||||||
)
|
|
||||||
|
|
||||||
// What the forge's take compares, as a machine would report it.
|
|
||||||
func aForgeComparison() comparison {
|
|
||||||
return comparison{reported: inventory.Adoption{
|
|
||||||
Firewall: "ufw",
|
|
||||||
Held: []inventory.Held{
|
|
||||||
{ID: "forge.server", Module: "forge", Kind: "container", Target: "forge", Facts: map[string]any{
|
|
||||||
"image": "forge:1.27.3", "image_created": "2026-09-17T10:00:00Z",
|
|
||||||
"declared_image": "forge:1.22.6", "declared_image_created": "2026-08-20T10:00:00Z", "downgrade": true,
|
|
||||||
"networks": map[string]any{"predecessor_default": []any{"office", "db"}},
|
|
||||||
"ports": []any{"3000/tcp>0.0.0.0:3000"}, "declared_ports": []any{"3000:3000"},
|
|
||||||
}},
|
|
||||||
{ID: "forge.config", Module: "forge", Kind: "file", Target: "/etc/forge/app.ini", Kept: "/var/lib/mesh/kept/app.ini",
|
|
||||||
Facts: map[string]any{"differs": true, "difference": []any{"- private scope: local", "+ upstream: public"}}},
|
|
||||||
{ID: "other.server", Module: "other", Kind: "container", Target: "other"},
|
|
||||||
},
|
|
||||||
Reachable: []inventory.Reach{
|
|
||||||
{Protocol: "tcp", Address: "0.0.0.0", Port: 3000, By: "forge", Published: true, ContainerPort: 3000},
|
|
||||||
{Protocol: "tcp", Address: "0.0.0.0", Port: 22, By: "sshd"},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A take is a comparison (novox/hq ADR 0163): the preview puts what runs beside what the module
|
|
||||||
// declares, and an older image or a differing file refuses unless named.
|
|
||||||
func TestATakePreviewsTheComparisonAndRefusesWhatIsNotNamed(t *testing.T) {
|
|
||||||
c := aForgeComparison()
|
|
||||||
preview, refusals, saw := comparisonOf("forge", c, takeOptions{})
|
|
||||||
for _, want := range []string{"runs forge:1.27.3 (made 2026-09-17)", "declares forge:1.22.6 (made 2026-08-20)", "DOWNGRADE",
|
|
||||||
"on the network predecessor_default with office, db", "will not once it moves to the module's own network",
|
|
||||||
"publishes 3000/tcp>0.0.0.0:3000; the module declares 3000:3000",
|
|
||||||
// How far the port reaches now, as the machine reported it (rule 1).
|
|
||||||
"reachable now at 0.0.0.0:3000 (tcp, container port 3000), behind the found firewall (ufw)",
|
|
||||||
"- private scope: local", "original kept at /var/lib/mesh/kept/app.ini"} {
|
|
||||||
if !strings.Contains(preview, want) {
|
|
||||||
t.Errorf("the preview lacks %q:\n%s", want, preview)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if strings.Contains(preview, "other") {
|
|
||||||
t.Errorf("another module's held things are in the preview:\n%s", preview)
|
|
||||||
}
|
|
||||||
if len(refusals) != 2 || !strings.Contains(refusals[0], "--downgrade") || !strings.Contains(refusals[1], "--replace /etc/forge/app.ini") {
|
|
||||||
t.Fatalf("the downgrade and the differing file refuse, each naming its override: %v", refusals)
|
|
||||||
}
|
|
||||||
if len(saw) != 12 {
|
|
||||||
t.Fatalf("the preview's digest is %q", saw)
|
|
||||||
}
|
|
||||||
// Named, they pass.
|
|
||||||
if _, refusals, _ := comparisonOf("forge", c, takeOptions{Downgrade: true, Replace: map[string]bool{"/etc/forge/app.ini": true}}); len(refusals) != 0 {
|
|
||||||
t.Fatalf("named differences still refused: %v", refusals)
|
|
||||||
}
|
|
||||||
if _, refusals, _ := comparisonOf("forge", c, takeOptions{Downgrade: true, Replace: map[string]bool{"*": true}}); len(refusals) != 0 {
|
|
||||||
t.Fatalf("replace * did not cover the file: %v", refusals)
|
|
||||||
}
|
|
||||||
// A held thing with no facts yet — a host older than this — refuses nothing and says what it can.
|
|
||||||
if preview, refusals, _ := comparisonOf("other", c, takeOptions{}); len(refusals) != 0 || !strings.Contains(preview, "container other") {
|
|
||||||
t.Fatalf("a factless hold: %q %v", preview, refusals)
|
|
||||||
}
|
|
||||||
// The digest is of what the preview says: a fact changing changes it.
|
|
||||||
c.reported.Held[0].Facts["image"] = "forge:1.27.4"
|
|
||||||
if _, _, again := comparisonOf("forge", c, takeOptions{}); again == saw {
|
|
||||||
t.Fatal("the found image changed and the digest did not")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A secret the mesh minted for a service whose data was found refuses: the running service already
|
|
||||||
// has a value (rule 2). Accepted, it is carried in; `--mint` says the service shall take the new one.
|
|
||||||
func TestAMintedSecretForFoundDataRefusesUnlessAcceptedOrMinted(t *testing.T) {
|
|
||||||
c := aForgeComparison()
|
|
||||||
c.secrets = []inventory.SecretState{
|
|
||||||
{Name: "admin", Origin: inventory.OriginMade},
|
|
||||||
{Name: "postgres-database", Origin: inventory.OriginMade, Provider: "anchor"},
|
|
||||||
{Name: "broker", Origin: inventory.OriginAccepted},
|
|
||||||
}
|
|
||||||
preview, refusals, _ := comparisonOf("forge", c, takeOptions{Downgrade: true, Replace: map[string]bool{"*": true}})
|
|
||||||
for _, want := range []string{
|
|
||||||
"own secret admin: MINTED by the mesh and not accepted",
|
|
||||||
"secret from anchor postgres-database: MINTED by the mesh and not accepted",
|
|
||||||
"own secret broker: accepted from a person, carried in as it is",
|
|
||||||
} {
|
|
||||||
if !strings.Contains(preview, want) {
|
|
||||||
t.Errorf("the preview lacks %q:\n%s", want, preview)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(refusals) != 2 {
|
|
||||||
t.Fatalf("two minted secrets refuse: %v", refusals)
|
|
||||||
}
|
|
||||||
if !strings.Contains(refusals[0], "`secret accept <node> forge admin`") || !strings.Contains(refusals[0], "`--mint admin`") {
|
|
||||||
t.Errorf("the own secret's refusal names accepting it and minting it: %s", refusals[0])
|
|
||||||
}
|
|
||||||
if !strings.Contains(refusals[1], "`secret accept <node> forge postgres-database --provider anchor`") {
|
|
||||||
t.Errorf("the required secret's refusal names its provider: %s", refusals[1])
|
|
||||||
}
|
|
||||||
preview, refusals, _ = comparisonOf("forge", c, takeOptions{Downgrade: true, Replace: map[string]bool{"*": true},
|
|
||||||
Mint: map[string]bool{"admin": true, "postgres-database": true}})
|
|
||||||
if len(refusals) != 0 || !strings.Contains(preview, "admin: minted by the mesh; the service takes the new value, as --mint said") {
|
|
||||||
t.Fatalf("--mint did not pass the minted secrets: %v\n%s", refusals, preview)
|
|
||||||
}
|
|
||||||
// With no found data — only a file held — the service has no value of its own, and a minted
|
|
||||||
// secret is simply said.
|
|
||||||
c.reported.Held = c.reported.Held[1:2]
|
|
||||||
if _, refusals, _ := comparisonOf("forge", c, takeOptions{Replace: map[string]bool{"*": true}}); len(refusals) != 0 {
|
|
||||||
t.Fatalf("a minted secret refused with no data found: %v", refusals)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A found network a per-machine setting keeps is named in the preview (rule 4), and the module's
|
|
||||||
// settings are said with where each came from, composed or not (rules 1 and 6).
|
|
||||||
func TestTheKeptNetworkAndTheSettingsAreInThePreview(t *testing.T) {
|
|
||||||
c := aForgeComparison()
|
|
||||||
c.keeps = map[string][]string{"forge.server": {"predecessor_default"}}
|
|
||||||
c.layers = []catalogue.Layer{
|
|
||||||
{From: catalogue.MeshWideLayer, Values: map[string]any{"site": "x"}},
|
|
||||||
{From: "anchor", Values: map[string]any{catalogue.NetworksSetting: map[string]any{"server": []any{"predecessor_default"}}}},
|
|
||||||
}
|
|
||||||
preview, _, _ := comparisonOf("forge", c, takeOptions{Downgrade: true, Replace: map[string]bool{"*": true}})
|
|
||||||
for _, want := range []string{
|
|
||||||
"on the network predecessor_default with office, db — kept by this machine's setting, so they still reach it by name once taken",
|
|
||||||
"settings from the mesh: site",
|
|
||||||
"settings from anchor: networks",
|
|
||||||
} {
|
|
||||||
if !strings.Contains(preview, want) {
|
|
||||||
t.Errorf("the preview lacks %q:\n%s", want, preview)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if strings.Contains(preview, "will not once it moves") {
|
|
||||||
t.Errorf("a kept network is still said to be lost:\n%s", preview)
|
|
||||||
}
|
|
||||||
c.settingsRefused = "forge: ports is a { port: machine-port } map"
|
|
||||||
preview, _, _ = comparisonOf("forge", c, takeOptions{Downgrade: true, Replace: map[string]bool{"*": true}})
|
|
||||||
if !strings.Contains(preview, "SETTINGS DO NOT COMPOSE with the module's definition, so the push leaves it out: forge: ports") {
|
|
||||||
t.Errorf("settings that cannot compose are not said:\n%s", preview)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -295,31 +295,17 @@ func (f following) SourceMoved(ctx context.Context, m link.SourceMoved) error {
|
|||||||
"built from\n", m.Owner, m.Repo, m.Base, m.Commit)
|
"built from\n", m.Owner, m.Repo, m.Base, m.Commit)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// A merge produces a plan the mesh keeps (novox/hq ADR 0162): what moved and everything that
|
against, err := inv.BuiltAgainst(ctx)
|
||||||
// depends on it, along the catalogue's one dependency relation, sorted into tiers. The plan is
|
|
||||||
// written before any build is asked; the first tier is asked; this returns. Outcomes advance it.
|
|
||||||
edges, err := inv.Dependencies(ctx)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return notNow(err)
|
return notNow(err)
|
||||||
}
|
}
|
||||||
var movedNames []string
|
ordered := orderByBases(moved, against)
|
||||||
for _, e := range moved {
|
names := make([]string, 0, len(ordered))
|
||||||
movedNames = append(movedNames, e.Manifest.Module)
|
for _, e := range ordered {
|
||||||
|
names = append(names, e.Manifest.Module)
|
||||||
}
|
}
|
||||||
plan := planOfMerge(m, movedNames, edges)
|
fmt.Printf("%s/%s merged into %s (%.8s); building %s\n",
|
||||||
if hasCycle(plan.Tiers, edges) {
|
m.Owner, m.Repo, m.Base, m.Commit, strings.Join(names, ", "))
|
||||||
fmt.Printf(" the last tier depends on itself: %s — built together, in no order\n",
|
|
||||||
strings.Join(plan.Tiers[len(plan.Tiers)-1], ", "))
|
|
||||||
}
|
|
||||||
if err := inv.SavePlan(ctx, plan); err != nil {
|
|
||||||
return notNow(err)
|
|
||||||
}
|
|
||||||
var tiers []string
|
|
||||||
for i, t := range plan.Tiers {
|
|
||||||
tiers = append(tiers, fmt.Sprintf("%d: %s", i, strings.Join(t, ", ")))
|
|
||||||
}
|
|
||||||
fmt.Printf("%s/%s merged into %s (%.8s); plan %s, %d module(s) in %d tier(s)\n %s\n",
|
|
||||||
m.Owner, m.Repo, m.Base, m.Commit, plan.ID, len(plan.Modules), len(plan.Tiers), strings.Join(tiers, "\n "))
|
|
||||||
if len(packaging) > 0 {
|
if len(packaging) > 0 {
|
||||||
var also []string
|
var also []string
|
||||||
for _, e := range packaging {
|
for _, e := range packaging {
|
||||||
@@ -328,11 +314,22 @@ func (f following) SourceMoved(ctx context.Context, m link.SourceMoved) error {
|
|||||||
fmt.Printf(" %s package source from it, so they are rebuilt and their own source record "+
|
fmt.Printf(" %s package source from it, so they are rebuilt and their own source record "+
|
||||||
"is left where it is\n", strings.Join(also, ", "))
|
"is left where it is\n", strings.Join(also, ", "))
|
||||||
}
|
}
|
||||||
if err := askTier(ctx, inv, &plan); err != nil {
|
var failed []string
|
||||||
return notNow(err)
|
for _, e := range ordered {
|
||||||
|
source := buildSource{Repository: e.Source.Repository, Seat: e.Source.Seat}
|
||||||
|
if err := buildOne(ctx, source, e.Source.Path, e.Source.Ref, 20*time.Minute); err != nil {
|
||||||
|
fmt.Printf(" %s: %v\n", e.Manifest.Module, err)
|
||||||
|
failed = append(failed, e.Manifest.Module)
|
||||||
|
// A base that failed is a reason to stop: what stands on it would be built against
|
||||||
|
// the old one, and report success (novox/hq 04-ISSUES/131).
|
||||||
|
if standsOn(ordered, e.Manifest.Module, against) {
|
||||||
|
fmt.Printf(" stopping: %s is a base of what was still to build\n", e.Manifest.Module)
|
||||||
|
break
|
||||||
}
|
}
|
||||||
if err := inv.SavePlan(ctx, plan); err != nil {
|
}
|
||||||
return notNow(err)
|
}
|
||||||
|
if len(failed) > 0 {
|
||||||
|
fmt.Printf("%d of %d not built: %s\n", len(failed), len(ordered), strings.Join(failed, ", "))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -525,37 +522,3 @@ func isHistory(mergedAt string, seen time.Time) bool {
|
|||||||
}
|
}
|
||||||
return at.Before(seen)
|
return at.Before(seen)
|
||||||
}
|
}
|
||||||
|
|
||||||
// dependentsOf is every catalogued module that stands on one of the moved modules, directly or
|
|
||||||
// through another dependent, and is not itself among them — in the catalogue's order, so the
|
|
||||||
// answer is the same each time. A module standing on nothing that moved is left alone: a merge
|
|
||||||
// rebuilds what it changed and what is built on top of that, not the catalogue.
|
|
||||||
func dependentsOf(moved, entries []inventory.Entry, against map[string][]string) []inventory.Entry {
|
|
||||||
bases := map[string]bool{}
|
|
||||||
for _, e := range moved {
|
|
||||||
bases[e.Manifest.Module] = true
|
|
||||||
}
|
|
||||||
var out []inventory.Entry
|
|
||||||
taken := map[string]bool{}
|
|
||||||
for grew := true; grew; {
|
|
||||||
grew = false
|
|
||||||
for _, e := range entries {
|
|
||||||
name := e.Manifest.Module
|
|
||||||
if bases[name] || taken[name] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for base := range bases {
|
|
||||||
if standsOnModule(e, base, against) {
|
|
||||||
taken[name] = true
|
|
||||||
out = append(out, e)
|
|
||||||
grew = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, e := range out {
|
|
||||||
bases[e.Manifest.Module] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,10 +10,7 @@
|
|||||||
# The client is copied from the vendor's own image rather than installed from a distribution:
|
# The client is copied from the vendor's own image rather than installed from a distribution:
|
||||||
# `apk add mc` on Alpine installs Midnight Commander, which is a different program with the same
|
# `apk add mc` on Alpine installs Midnight Commander, which is a different program with the same
|
||||||
# name, and the failure would be a provisioner that starts cleanly and cannot do anything.
|
# name, and the failure would be a provisioner that starts cleanly and cannot do anything.
|
||||||
# The Go it builds with is the one the manifest pins (build.on GO_BASE), passed by the Makefile and the
|
FROM golang:1.25-alpine AS build
|
||||||
# build machine alike; the default only serves a hand build, and matches go.mod.
|
|
||||||
ARG GO_BASE=golang:1.26-alpine
|
|
||||||
FROM ${GO_BASE} AS build
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|||||||
@@ -3,10 +3,7 @@
|
|||||||
# Built here so a machine can be given it by the mesh rather than by somebody putting a binary on
|
# Built here so a machine can be given it by the mesh rather than by somebody putting a binary on
|
||||||
# it. Static and FROM scratch for the same reason the control plane's image is: it is fetched by
|
# it. Static and FROM scratch for the same reason the control plane's image is: it is fetched by
|
||||||
# digest and run on a machine, and everything in it is something a person would have to audit.
|
# digest and run on a machine, and everything in it is something a person would have to audit.
|
||||||
# The Go it builds with is the one the manifest pins (build.on GO_BASE), passed by the Makefile and the
|
FROM golang:1.25-alpine AS build
|
||||||
# build machine alike; the default only serves a hand build, and matches go.mod.
|
|
||||||
ARG GO_BASE=golang:1.26-alpine
|
|
||||||
FROM ${GO_BASE} AS build
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|||||||
@@ -2,10 +2,7 @@
|
|||||||
#
|
#
|
||||||
# FROM scratch, like the postgres one and unlike the bucket one: it speaks the store's own wire
|
# FROM scratch, like the postgres one and unlike the bucket one: it speaks the store's own wire
|
||||||
# protocol directly and needs no client in the image.
|
# protocol directly and needs no client in the image.
|
||||||
# The Go it builds with is the one the manifest pins (build.on GO_BASE), passed by the Makefile and the
|
FROM golang:1.25-alpine AS build
|
||||||
# build machine alike; the default only serves a hand build, and matches go.mod.
|
|
||||||
ARG GO_BASE=golang:1.26-alpine
|
|
||||||
FROM ${GO_BASE} AS build
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|||||||
@@ -2,10 +2,7 @@
|
|||||||
#
|
#
|
||||||
# Static and FROM scratch like the control plane's image, and for the same reason: it is fetched
|
# Static and FROM scratch like the control plane's image, and for the same reason: it is fetched
|
||||||
# by digest and run on a machine, so everything in it is something a person would have to audit.
|
# by digest and run on a machine, so everything in it is something a person would have to audit.
|
||||||
# The Go it builds with is the one the manifest pins (build.on GO_BASE), passed by the Makefile and the
|
FROM golang:1.25-alpine AS build
|
||||||
# build machine alike; the default only serves a hand build, and matches go.mod.
|
|
||||||
ARG GO_BASE=golang:1.26-alpine
|
|
||||||
FROM ${GO_BASE} AS build
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"sync/atomic"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/nats-io/nats.go"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/broker"
|
|
||||||
)
|
|
||||||
|
|
||||||
// What the mesh issued this proxy, read on the bus (novox/hq ADR 0160, ADR 0167).
|
|
||||||
//
|
|
||||||
// **The proxy is told, not left to work it out.** Its membership carries the routes it is given —
|
|
||||||
// the same contributions its file is written from — and every machine's address on the private
|
|
||||||
// network, which is who may be served an internal name. Read once at connect and followed, so a
|
|
||||||
// route added or a machine joining reaches a running proxy without a restart.
|
|
||||||
|
|
||||||
// credential is the bus account the mesh delivered as this module's own secret named broker.
|
|
||||||
type credential struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
Fingerprint string `json:"fingerprint"`
|
|
||||||
Node string `json:"node"`
|
|
||||||
Module string `json:"module"`
|
|
||||||
User string `json:"user"`
|
|
||||||
Password string `json:"password"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// followMembership connects with the credential in path and applies every membership the mesh
|
|
||||||
// issues this proxy. It retries the first connection for as long as it takes: a proxy that started
|
|
||||||
// before the bus keeps serving the file, and takes the bus when it answers.
|
|
||||||
func followMembership(path string, held *table, fromBus *atomic.Bool) {
|
|
||||||
for {
|
|
||||||
err := followOnce(path, held, fromBus)
|
|
||||||
if err == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Printf("cannot follow this proxy's membership, serving the file meanwhile: %v", err)
|
|
||||||
time.Sleep(30 * time.Second)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func followOnce(path string, held *table, fromBus *atomic.Bool) error {
|
|
||||||
raw, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var cred credential
|
|
||||||
if err := json.Unmarshal(raw, &cred); err != nil {
|
|
||||||
return fmt.Errorf("the broker credential is not one: %w", err)
|
|
||||||
}
|
|
||||||
if cred.Node == "" || cred.Module == "" {
|
|
||||||
return fmt.Errorf("the broker credential names no node or module, so it has no membership")
|
|
||||||
}
|
|
||||||
|
|
||||||
opts := []nats.Option{
|
|
||||||
nats.Name(cred.Node + "." + cred.Module),
|
|
||||||
nats.UserInfo(cred.User, cred.Password),
|
|
||||||
// Its own inbox, and nothing wider: every principal is granted `_INBOX.<its user>.>` alone.
|
|
||||||
nats.CustomInboxPrefix("_INBOX." + cred.User),
|
|
||||||
// The bus being restarted is an upgrade, not a reason to stop following.
|
|
||||||
nats.MaxReconnects(-1),
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(cred.Fingerprint) != "" {
|
|
||||||
opts = append(opts, nats.Secure(broker.PinnedToFingerprint(cred.Fingerprint)))
|
|
||||||
}
|
|
||||||
conn, err := nats.Connect(cred.URL, opts...)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("connecting to the bus at %s: %w", broker.BareAddress(cred.URL), err)
|
|
||||||
}
|
|
||||||
|
|
||||||
subject := broker.MembershipSubject(cred.Node, cred.Module)
|
|
||||||
apply := func(body []byte) {
|
|
||||||
var issued broker.Membership
|
|
||||||
if err := json.Unmarshal(body, &issued); err != nil {
|
|
||||||
log.Printf("a membership arrived that is not one: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if took := applyMembership(issued, held); took && !fromBus.Swap(true) {
|
|
||||||
log.Printf("routes now come from this proxy's membership on %s", subject)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Followed first, read second: an issue landing between the two is applied, not missed.
|
|
||||||
if _, err := conn.Subscribe(subject, func(m *nats.Msg) { apply(m.Data) }); err != nil {
|
|
||||||
conn.Close()
|
|
||||||
return fmt.Errorf("cannot follow %s: %w", subject, err)
|
|
||||||
}
|
|
||||||
// The subject-addressed direct get: the one request this account may make of the stream.
|
|
||||||
got, err := conn.Request("$JS.API.DIRECT.GET."+broker.AssignmentsStream+"."+subject, nil, 5*time.Second)
|
|
||||||
switch {
|
|
||||||
case err != nil:
|
|
||||||
log.Printf("cannot read the membership issued on %s yet (%v); following it", subject, err)
|
|
||||||
case got.Header.Get("Status") != "" || len(got.Data) == 0:
|
|
||||||
log.Printf("no membership issued on %s yet; serving the file until one is", subject)
|
|
||||||
default:
|
|
||||||
apply(got.Data)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// applyMembership serves what a membership says, and says whether it said anything about routes.
|
|
||||||
//
|
|
||||||
// A membership with no routes in it is one from a controller older than ADR 0167, and the file stays
|
|
||||||
// the source rather than every route being withdrawn because a field was absent.
|
|
||||||
func applyMembership(issued broker.Membership, held *table) bool {
|
|
||||||
raw, carries := issued.Receives["route"]
|
|
||||||
if !carries {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
var contributions []contribution
|
|
||||||
if err := json.Unmarshal(raw, &contributions); err != nil {
|
|
||||||
log.Printf("the routes in this proxy's membership are not contributions, keeping what is served: %v", err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
inside, err := sourcesOf(issued.Mesh)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("the mesh in this proxy's membership is unreadable, keeping what is served: %v", err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
routes, public := routesOf(contributions)
|
|
||||||
held.set(routes, public)
|
|
||||||
held.setInside(inside)
|
|
||||||
log.Printf("serving %d route(s) from the membership, internal names to %d machine(s): %s",
|
|
||||||
len(routes), len(inside), strings.Join(held.names(), ", "))
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
+28
-179
@@ -54,14 +54,12 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
"net/netip"
|
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/crypto/acme"
|
"golang.org/x/crypto/acme"
|
||||||
@@ -198,63 +196,6 @@ type table struct {
|
|||||||
// pass ACME's own validation (it has no public DNS to prove it against), so asking for it is
|
// pass ACME's own validation (it has no public DNS to prove it against), so asking for it is
|
||||||
// not merely pointless but the failing order onlyWhatTheMeshSaid exists to prevent.
|
// not merely pointless but the failing order onlyWhatTheMeshSaid exists to prevent.
|
||||||
public map[string]bool
|
public map[string]bool
|
||||||
// inside is where a request must come from to be served a name that is only internal: every
|
|
||||||
// machine's address on the private network, as the mesh issued it in this proxy's membership
|
|
||||||
// (novox/hq ADR 0167). Empty until it is issued, and then only the machine itself is inside.
|
|
||||||
inside sources
|
|
||||||
}
|
|
||||||
|
|
||||||
// sources is who may be served an internal name: the private network's addresses as the mesh
|
|
||||||
// issued them. The machine itself is always inside — anything on a machine may call anything on it
|
|
||||||
// (novox/hq ADR 0144) — so loopback needs no entry.
|
|
||||||
type sources []netip.Prefix
|
|
||||||
|
|
||||||
// sourcesOf reads the addresses the mesh issued, each a single address or a range. One that does
|
|
||||||
// not parse is an error, not an entry skipped: the proxy would otherwise serve internal names to
|
|
||||||
// fewer machines than the mesh said, and say nothing.
|
|
||||||
func sourcesOf(mesh []string) (sources, error) {
|
|
||||||
var out sources
|
|
||||||
for _, entry := range mesh {
|
|
||||||
entry = strings.TrimSpace(entry)
|
|
||||||
if prefix, err := netip.ParsePrefix(entry); err == nil {
|
|
||||||
out = append(out, prefix.Masked())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
addr, err := netip.ParseAddr(entry)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%q is not an address on the private network", entry)
|
|
||||||
}
|
|
||||||
addr = addr.Unmap()
|
|
||||||
out = append(out, netip.PrefixFrom(addr, addr.BitLen()))
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// holds says whether a request from this remote address came from the mesh or the machine itself.
|
|
||||||
//
|
|
||||||
// **By source, which the mesh's guard deliberately is not** — it names interfaces, because a source
|
|
||||||
// address can be claimed by whoever sends the packet. The proxy cannot see the interface a request
|
|
||||||
// arrived on, and here the claim does not carry: a connection needs its replies, and replies to a
|
|
||||||
// mesh address leave by the tunnel, never back to the claimant.
|
|
||||||
func (s sources) holds(remote string) bool {
|
|
||||||
host := remote
|
|
||||||
if h, _, err := net.SplitHostPort(remote); err == nil {
|
|
||||||
host = h
|
|
||||||
}
|
|
||||||
addr, err := netip.ParseAddr(host)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
addr = addr.Unmap()
|
|
||||||
if addr.IsLoopback() {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
for _, prefix := range s {
|
|
||||||
if prefix.Contains(addr) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *table) set(routes map[string][]rule, public map[string]bool) {
|
func (t *table) set(routes map[string][]rule, public map[string]bool) {
|
||||||
@@ -373,41 +314,6 @@ func bareHost(host string) string {
|
|||||||
return strings.ToLower(host)
|
return strings.ToLower(host)
|
||||||
}
|
}
|
||||||
|
|
||||||
// hiddenFrom says whether this host must look unrouted to a request from this address: it is
|
|
||||||
// only an internal name, and the request did not come from the private network.
|
|
||||||
//
|
|
||||||
// **The proxy is the only way in to a routed endpoint, so it is what makes `internal` true**
|
|
||||||
// (novox/hq ADR 0138, issue 191). It answers public names on the same listeners, so a request from
|
|
||||||
// anywhere can carry any Host header; a name being internal keeps nobody out unless this check does.
|
|
||||||
// Answered exactly as a name that was never routed, so an outsider learns nothing from asking.
|
|
||||||
func (t *table) hiddenFrom(host, remote string) bool {
|
|
||||||
if !t.eligibleForInternalACME(host) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
t.mu.RLock()
|
|
||||||
defer t.mu.RUnlock()
|
|
||||||
return !t.inside.holds(remote)
|
|
||||||
}
|
|
||||||
|
|
||||||
// setInside replaces who the mesh is, as the membership said.
|
|
||||||
func (t *table) setInside(inside sources) {
|
|
||||||
t.mu.Lock()
|
|
||||||
t.inside = inside
|
|
||||||
t.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// namesSeenFrom is what this proxy says it serves to a request from this address — every routed
|
|
||||||
// name, less the internal-only ones when the request came from outside.
|
|
||||||
func (t *table) namesSeenFrom(remote string) []string {
|
|
||||||
out := []string{}
|
|
||||||
for _, name := range t.names() {
|
|
||||||
if !t.hiddenFrom(name, remote) {
|
|
||||||
out = append(out, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *table) names() []string {
|
func (t *table) names() []string {
|
||||||
t.mu.RLock()
|
t.mu.RLock()
|
||||||
defer t.mu.RUnlock()
|
defer t.mu.RUnlock()
|
||||||
@@ -437,20 +343,7 @@ func run() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
held := newTable()
|
held := newTable()
|
||||||
// **The bus first, the file until it has spoken** (novox/hq ADR 0167). The membership carries
|
|
||||||
// the routes and who the mesh is; the file carries the routes alone, so while the proxy reads
|
|
||||||
// it an internal name is served to this machine and to nobody else — refused, never opened.
|
|
||||||
fromBus := &atomic.Bool{}
|
|
||||||
if credential := strings.TrimSpace(os.Getenv("MESH_BROKER_FILE")); credential != "" {
|
|
||||||
go followMembership(credential, held, fromBus)
|
|
||||||
} else {
|
|
||||||
log.Printf("MESH_BROKER_FILE is not set: routes come from %s alone, and a name that is only "+
|
|
||||||
"internal is served to this machine alone", path)
|
|
||||||
}
|
|
||||||
read := func() {
|
read := func() {
|
||||||
if fromBus.Load() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
routes, public, err := routesFrom(path)
|
routes, public, err := routesFrom(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Kept serving what it had. A file being rewritten is momentarily unreadable, and
|
// Kept serving what it had. A file being rewritten is momentarily unreadable, and
|
||||||
@@ -529,7 +422,19 @@ func run() error {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
tlsConfig := publicManager.TLSConfig()
|
tlsConfig := publicManager.TLSConfig()
|
||||||
tlsConfig.GetCertificate = certificateFor(held, tlsConfig.GetCertificate, internalManager)
|
if internalManager != nil {
|
||||||
|
// Dispatched by which authority may certify this name at all — the same question
|
||||||
|
// eligibleForInternalACME already answers, asked once more at handshake time rather than
|
||||||
|
// only when an order is placed, since a cached certificate is served here on every request
|
||||||
|
// and never goes through HostPolicy again.
|
||||||
|
fromPublic, fromInternal := tlsConfig.GetCertificate, internalManager.TLSConfig().GetCertificate
|
||||||
|
tlsConfig.GetCertificate = func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||||
|
if held.eligibleForInternalACME(hello.ServerName) {
|
||||||
|
return fromInternal(hello)
|
||||||
|
}
|
||||||
|
return fromPublic(hello)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: secure,
|
Addr: secure,
|
||||||
@@ -687,33 +592,6 @@ func forThisAuthority(cache, directory string, root []byte) string {
|
|||||||
return filepath.Join(cache, hex.EncodeToString(sum[:])[:16])
|
return filepath.Join(cache, hex.EncodeToString(sum[:])[:16])
|
||||||
}
|
}
|
||||||
|
|
||||||
// certificateFor picks the certificate a handshake is answered with.
|
|
||||||
//
|
|
||||||
// Dispatched by which authority may certify this name at all — the same question
|
|
||||||
// eligibleForInternalACME already answers, asked once more at handshake time rather than only when
|
|
||||||
// an order is placed, since a cached certificate is served here on every request and never goes
|
|
||||||
// through HostPolicy again. And refused, exactly as an unrouted name is, to a client outside the
|
|
||||||
// private network asking for a name that is only internal: the certificate would name it.
|
|
||||||
func certificateFor(held *table, fromPublic func(*tls.ClientHelloInfo) (*tls.Certificate, error),
|
|
||||||
internalManager *autocert.Manager) func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
|
|
||||||
var fromInternal func(*tls.ClientHelloInfo) (*tls.Certificate, error)
|
|
||||||
if internalManager != nil {
|
|
||||||
fromInternal = internalManager.TLSConfig().GetCertificate
|
|
||||||
}
|
|
||||||
return func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
|
||||||
if held.eligibleForInternalACME(hello.ServerName) {
|
|
||||||
if hello.Conn != nil && held.hiddenFrom(hello.ServerName, hello.Conn.RemoteAddr().String()) {
|
|
||||||
return nil, fmt.Errorf("no public route for %q in this mesh, so no certificate is asked for",
|
|
||||||
hello.ServerName)
|
|
||||||
}
|
|
||||||
if fromInternal != nil {
|
|
||||||
return fromInternal(hello)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fromPublic(hello)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// newTable is an empty routing table.
|
// newTable is an empty routing table.
|
||||||
func newTable() *table {
|
func newTable() *table {
|
||||||
return &table{to: map[string][]rule{}}
|
return &table{to: map[string][]rule{}}
|
||||||
@@ -722,9 +600,8 @@ func newTable() *table {
|
|||||||
// handler is the proxy itself, separated so it can be driven by a test without a listener.
|
// handler is the proxy itself, separated so it can be driven by a test without a listener.
|
||||||
func handler(held *table) http.Handler {
|
func handler(held *table) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
hidden := held.hiddenFrom(r.Host, r.RemoteAddr)
|
|
||||||
matched, known := held.find(r.Host, r.URL.Path)
|
matched, known := held.find(r.Host, r.URL.Path)
|
||||||
if hidden || !known {
|
if !known {
|
||||||
// **Named, not a bare 404.** A route that was withdrawn and a name that never existed
|
// **Named, not a bare 404.** A route that was withdrawn and a name that never existed
|
||||||
// are different things, and a proxy that says only "not found" makes an operator go
|
// are different things, and a proxy that says only "not found" makes an operator go
|
||||||
// and read the mesh to tell them apart. What it is serving is the answer to both.
|
// and read the mesh to tell them apart. What it is serving is the answer to both.
|
||||||
@@ -732,20 +609,15 @@ func handler(held *table) http.Handler {
|
|||||||
// And since a host may now be routed only on some paths, those are a third thing:
|
// And since a host may now be routed only on some paths, those are a third thing:
|
||||||
// saying "no route for this name" while listing that very name as served is a
|
// saying "no route for this name" while listing that very name as served is a
|
||||||
// contradiction an operator would have to disbelieve the proxy to get past.
|
// contradiction an operator would have to disbelieve the proxy to get past.
|
||||||
// **Said in the log as well as to the client.** A name this mesh does not serve, asked
|
|
||||||
// for from outside, is what a scanner does, and the machine's intrusion prevention reads
|
|
||||||
// this proxy's log for exactly that line (novox/hq ADR 0179): the address last, as the
|
|
||||||
// jail's filter expects it.
|
|
||||||
log.Printf("refused: no route for %q, asked from %s", r.Host, r.RemoteAddr)
|
|
||||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
w.WriteHeader(http.StatusNotFound)
|
w.WriteHeader(http.StatusNotFound)
|
||||||
if !hidden && held.routed(r.Host) {
|
if held.routed(r.Host) {
|
||||||
fmt.Fprintf(w, "%s is served here, but no route covers %q.\n",
|
fmt.Fprintf(w, "%s is served here, but no route covers %q.\n",
|
||||||
bareHost(r.Host), r.URL.Path)
|
bareHost(r.Host), r.URL.Path)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, "no route for %q in this mesh.\nserving: %s\n",
|
fmt.Fprintf(w, "no route for %q in this mesh.\nserving: %s\n",
|
||||||
r.Host, strings.Join(held.namesSeenFrom(r.RemoteAddr), ", "))
|
r.Host, strings.Join(held.names(), ", "))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -844,12 +716,6 @@ func boolByte(b bool) byte {
|
|||||||
// routesFrom reads what the mesh wrote and turns it into host → the rules for that host, and
|
// routesFrom reads what the mesh wrote and turns it into host → the rules for that host, and
|
||||||
// which of those hosts is a public name — the second is `name`, ACME-eligible; a host reached
|
// which of those hosts is a public name — the second is `name`, ACME-eligible; a host reached
|
||||||
// only through `internal-name` never appears there.
|
// only through `internal-name` never appears there.
|
||||||
//
|
|
||||||
// **A route may carry either name, or both** (novox/hq ADR 0138). How far an endpoint reaches
|
|
||||||
// decides which names the mesh composes, so an endpoint that reaches only the private network
|
|
||||||
// arrives with an `internal-name` and no `name`. That is a whole route, not a malformed one: it is
|
|
||||||
// served under its internal name and certified by the internal authority. Only a route with
|
|
||||||
// neither name has nothing to be served under (novox/hq issue 191).
|
|
||||||
func routesFrom(path string) (map[string][]rule, map[string]bool, error) {
|
func routesFrom(path string) (map[string][]rule, map[string]bool, error) {
|
||||||
raw, err := os.ReadFile(path)
|
raw, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -859,29 +725,17 @@ func routesFrom(path string) (map[string][]rule, map[string]bool, error) {
|
|||||||
if err := json.Unmarshal(raw, &said); err != nil {
|
if err := json.Unmarshal(raw, &said); err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
routes, public := routesOf(said.Given)
|
|
||||||
return routes, public, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// routesOf turns what the mesh gave into host → the rules for that host, and which hosts are public
|
|
||||||
// names — the same whether the contributions came in the file or in the membership.
|
|
||||||
func routesOf(contributions []contribution) (map[string][]rule, map[string]bool) {
|
|
||||||
out := map[string][]rule{}
|
out := map[string][]rule{}
|
||||||
public := map[string]bool{}
|
public := map[string]bool{}
|
||||||
for _, c := range contributions {
|
for _, c := range said.Given {
|
||||||
name, _ := c.Values["name"].(string)
|
name, _ := c.Values["name"].(string)
|
||||||
name = strings.TrimSpace(name)
|
if name == "" {
|
||||||
internal, _ := c.Values["internal-name"].(string)
|
|
||||||
internal = strings.TrimSpace(internal)
|
|
||||||
if name == "" && internal == "" {
|
|
||||||
log.Printf("%s on %s asked for a route and named nothing; skipped", c.From, c.Node)
|
log.Printf("%s on %s asked for a route and named nothing; skipped", c.From, c.Node)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// What the route is called in a log line: its public name when it has one.
|
host := strings.ToLower(name)
|
||||||
called := name
|
public[host] = true
|
||||||
if called == "" {
|
|
||||||
called = internal
|
|
||||||
}
|
|
||||||
|
|
||||||
made := rule{path: asPath(c.Values["path"])}
|
made := rule{path: asPath(c.Values["path"])}
|
||||||
if p, ok := asWhole(c.Values["priority"]); ok {
|
if p, ok := asWhole(c.Values["priority"]); ok {
|
||||||
@@ -898,7 +752,7 @@ func routesOf(contributions []contribution) (map[string][]rule, map[string]bool)
|
|||||||
if looksLikeACredential(named) {
|
if looksLikeACredential(named) {
|
||||||
log.Printf("%s on %s declared route %q with a credential in the declaration rather "+
|
log.Printf("%s on %s declared route %q with a credential in the declaration rather "+
|
||||||
"than the name of a secret; the whole route is refused (novox/hq ADR 0108)",
|
"than the name of a secret; the whole route is refused (novox/hq ADR 0108)",
|
||||||
c.From, c.Node, called)
|
c.From, c.Node, name)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
users, err := usersFrom(named)
|
users, err := usersFrom(named)
|
||||||
@@ -916,7 +770,7 @@ func routesOf(contributions []contribution) (map[string][]rule, map[string]bool)
|
|||||||
port, ok := asPort(c.Values["port"])
|
port, ok := asPort(c.Values["port"])
|
||||||
if !ok {
|
if !ok {
|
||||||
log.Printf("%s on %s asked for route %q and gave no usable port; skipped",
|
log.Printf("%s on %s asked for route %q and gave no usable port; skipped",
|
||||||
c.From, c.Node, called)
|
c.From, c.Node, name)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Where the mesh says that machine is. Empty means it is this one — a workload beside
|
// Where the mesh says that machine is. Empty means it is this one — a workload beside
|
||||||
@@ -937,7 +791,7 @@ func routesOf(contributions []contribution) (map[string][]rule, map[string]bool)
|
|||||||
}
|
}
|
||||||
if scheme != "http" && scheme != "https" {
|
if scheme != "http" && scheme != "https" {
|
||||||
log.Printf("%s on %s asked for route %q with scheme %q, which is neither http "+
|
log.Printf("%s on %s asked for route %q with scheme %q, which is neither http "+
|
||||||
"nor https; skipped", c.From, c.Node, called, scheme)
|
"nor https; skipped", c.From, c.Node, name, scheme)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
made.insecure, _ = c.Values["insecure"].(bool)
|
made.insecure, _ = c.Values["insecure"].(bool)
|
||||||
@@ -948,7 +802,7 @@ func routesOf(contributions []contribution) (map[string][]rule, map[string]bool)
|
|||||||
bytes, whole := asWhole(asked)
|
bytes, whole := asWhole(asked)
|
||||||
if !whole || bytes <= 0 {
|
if !whole || bytes <= 0 {
|
||||||
log.Printf("%s on %s asked for route %q with a max-request-body of %v, which is "+
|
log.Printf("%s on %s asked for route %q with a max-request-body of %v, which is "+
|
||||||
"not a whole positive number of bytes; skipped", c.From, c.Node, called, asked)
|
"not a whole positive number of bytes; skipped", c.From, c.Node, name, asked)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
made.maxRequestBody = int64(bytes)
|
made.maxRequestBody = int64(bytes)
|
||||||
@@ -956,24 +810,19 @@ func routesOf(contributions []contribution) (map[string][]rule, map[string]bool)
|
|||||||
made.target = fmt.Sprintf("%s://%s:%d", scheme, at, port)
|
made.target = fmt.Sprintf("%s://%s:%d", scheme, at, port)
|
||||||
}
|
}
|
||||||
|
|
||||||
if name != "" {
|
|
||||||
host := strings.ToLower(name)
|
|
||||||
out[host] = append(out[host], made)
|
out[host] = append(out[host], made)
|
||||||
public[host] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// The internal-network name, the same rule under a second host — a predecessor proxy
|
// The internal-network alias, the same rule under a second host — a predecessor proxy
|
||||||
// answered both for one route, as a convenience (reaching a service over the VPN without a
|
// answered both for one route, as a convenience (reaching a service over the VPN without a
|
||||||
// public TLS round trip), not as an access boundary; composing it here restores exactly
|
// public TLS round trip), not as an access boundary; composing it here restores exactly
|
||||||
// that, nothing more. Absent whenever the node composed no internal name (novox/hq ADR
|
// that, nothing more. Absent whenever the node composed no internal name (novox/hq ADR
|
||||||
// 0056's internalDomain half) — the same "nothing to join a label to" case the public name
|
// 0056's internalDomain half) — the same "nothing to join a label to" case the public name
|
||||||
// already has. And the only name, when the endpoint reaches no further than the private
|
// already has.
|
||||||
// network.
|
if internal, _ := c.Values["internal-name"].(string); strings.TrimSpace(internal) != "" {
|
||||||
if internal != "" {
|
|
||||||
out[strings.ToLower(internal)] = append(out[strings.ToLower(internal)], made)
|
out[strings.ToLower(internal)] = append(out[strings.ToLower(internal)], made)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out, public
|
return out, public, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// asWhole is any whole number the mesh wrote, whatever its magnitude.
|
// asWhole is any whole number the mesh wrote, whatever its magnitude.
|
||||||
|
|||||||
@@ -1,206 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/tls"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/broker"
|
|
||||||
)
|
|
||||||
|
|
||||||
// behind is a workload the proxy can send to, and a table routing one public name and one
|
|
||||||
// internal-only name to it, with the mesh's machines as the membership would issue them.
|
|
||||||
func behind(t *testing.T, mesh ...string) *table {
|
|
||||||
t.Helper()
|
|
||||||
workload := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
io.WriteString(w, "the workload")
|
|
||||||
}))
|
|
||||||
t.Cleanup(workload.Close)
|
|
||||||
at, _ := url.Parse(workload.URL)
|
|
||||||
host, port, _ := net.SplitHostPort(at.Host)
|
|
||||||
|
|
||||||
routes, public, err := routesFrom(write(t, fmt.Sprintf(`{"given":[
|
|
||||||
{"from":"app","node":"anchor","at":%q,
|
|
||||||
"values":{"name":"app.example","internal-name":"app.anchor.internal","port":%s}},
|
|
||||||
{"from":"admin","node":"anchor","at":%q,
|
|
||||||
"values":{"internal-name":"admin.anchor.internal","port":%s}}
|
|
||||||
]}`, host, port, host, port)))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
held := newTable()
|
|
||||||
inside, err := sourcesOf(mesh)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
held.setInside(inside)
|
|
||||||
held.set(routes, public)
|
|
||||||
return held
|
|
||||||
}
|
|
||||||
|
|
||||||
// askFrom is what the proxy answers a request for host coming from remote.
|
|
||||||
func askFrom(held *table, host, remote string) (int, string) {
|
|
||||||
r := httptest.NewRequest(http.MethodGet, "http://"+host+"/", nil)
|
|
||||||
r.RemoteAddr = remote
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
handler(held).ServeHTTP(w, r)
|
|
||||||
return w.Code, w.Body.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// **An internal-only name is served to the private network and to nobody else** (novox/hq ADR
|
|
||||||
// 0138, issue 191). The proxy answers public names on the same listeners, so without this a name
|
|
||||||
// being internal kept nobody out: a request from the internet only had to carry it.
|
|
||||||
func TestAnInternalOnlyNameIsServedOnlyInsideThePrivateNetwork(t *testing.T) {
|
|
||||||
held := behind(t, "10.10.0.1", "10.10.0.7")
|
|
||||||
|
|
||||||
if code, body := askFrom(held, "admin.anchor.internal", "10.10.0.7:51000"); code != http.StatusOK ||
|
|
||||||
body != "the workload" {
|
|
||||||
t.Errorf("a request from the private network was not served: %d %q", code, body)
|
|
||||||
}
|
|
||||||
if code, body := askFrom(held, "admin.anchor.internal", "127.0.0.1:51000"); code != http.StatusOK {
|
|
||||||
t.Errorf("a request from the machine itself was not served: %d %q", code, body)
|
|
||||||
}
|
|
||||||
|
|
||||||
code, body := askFrom(held, "admin.anchor.internal", "203.0.113.9:51000")
|
|
||||||
if code != http.StatusNotFound {
|
|
||||||
t.Fatalf("a request from outside the private network reached an internal-only name: %d %q",
|
|
||||||
code, body)
|
|
||||||
}
|
|
||||||
// Answered as a name never routed, and the list of what is served does not name it either —
|
|
||||||
// otherwise the refusal would tell an outsider exactly what to ask for from inside.
|
|
||||||
if strings.Contains(strings.SplitN(body, "\n", 2)[1], "admin.anchor.internal") {
|
|
||||||
t.Errorf("the refusal names the internal-only route to an outsider: %q", body)
|
|
||||||
}
|
|
||||||
if !strings.Contains(body, "app.example") {
|
|
||||||
t.Errorf("the refusal stopped listing the public names: %q", body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The internal name of a route that also has a public one is internal too: served inside, and to
|
|
||||||
// an outsider only under the public name. Nothing is lost — the outsider has the public name — and a
|
|
||||||
// name stays one thing whichever route it came from.
|
|
||||||
func TestAnInternalAliasOfAPublicRouteIsServedInsideOnly(t *testing.T) {
|
|
||||||
held := behind(t, "10.10.0.1", "10.10.0.7")
|
|
||||||
if code, body := askFrom(held, "app.anchor.internal", "10.10.0.7:51000"); code != http.StatusOK {
|
|
||||||
t.Errorf("the internal alias stopped answering the private network: %d %q", code, body)
|
|
||||||
}
|
|
||||||
if code, _ := askFrom(held, "app.anchor.internal", "203.0.113.9:51000"); code != http.StatusNotFound {
|
|
||||||
t.Errorf("the internal alias was served to an outsider: %d", code)
|
|
||||||
}
|
|
||||||
if code, _ := askFrom(held, "app.example", "203.0.113.9:51000"); code != http.StatusOK {
|
|
||||||
t.Errorf("the public name was refused to an outsider: %d", code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Before a membership has said who the mesh is, only the machine itself is inside — refused to
|
|
||||||
// everyone else, never served to everyone.
|
|
||||||
func TestUntilTheMeshIsIssuedAnInternalOnlyNameIsServedToTheMachineAlone(t *testing.T) {
|
|
||||||
held := behind(t)
|
|
||||||
if code, _ := askFrom(held, "admin.anchor.internal", "10.10.0.7:51000"); code != http.StatusNotFound {
|
|
||||||
t.Errorf("an internal-only name was served with no private network said: %d", code)
|
|
||||||
}
|
|
||||||
if code, _ := askFrom(held, "admin.anchor.internal", "[::1]:51000"); code != http.StatusOK {
|
|
||||||
t.Errorf("an internal-only name was refused to the machine itself: %d", code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type from struct {
|
|
||||||
net.Conn
|
|
||||||
remote net.Addr
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c from) RemoteAddr() net.Addr { return c.remote }
|
|
||||||
|
|
||||||
// The handshake refuses an internal-only name to an outsider too: the certificate would name it,
|
|
||||||
// and serving it would answer the question the routing refuses to.
|
|
||||||
func TestTheHandshakeRefusesAnInternalOnlyNameToAnOutsider(t *testing.T) {
|
|
||||||
held := behind(t, "10.10.0.1", "10.10.0.7")
|
|
||||||
served := &tls.Certificate{}
|
|
||||||
pick := certificateFor(held, func(*tls.ClientHelloInfo) (*tls.Certificate, error) { return served, nil }, nil)
|
|
||||||
hello := func(name, remote string) *tls.ClientHelloInfo {
|
|
||||||
addr, _ := net.ResolveTCPAddr("tcp", remote)
|
|
||||||
return &tls.ClientHelloInfo{ServerName: name, Conn: from{remote: addr}}
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := pick(hello("admin.anchor.internal", "203.0.113.9:443")); err == nil {
|
|
||||||
t.Error("an outsider was handed a certificate for an internal-only name")
|
|
||||||
}
|
|
||||||
if got, err := pick(hello("admin.anchor.internal", "10.10.0.7:443")); err != nil || got != served {
|
|
||||||
t.Errorf("a client on the private network was refused: %v", err)
|
|
||||||
}
|
|
||||||
if got, err := pick(hello("app.example", "203.0.113.9:443")); err != nil || got != served {
|
|
||||||
t.Errorf("a public name was refused to an outsider: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The mesh is issued as machines' addresses; a range is read as well. One that does not parse is
|
|
||||||
// refused rather than skipped, so a typo never quietly narrows or widens who is inside.
|
|
||||||
func TestTheMeshIsReadAsAddressesAndRanges(t *testing.T) {
|
|
||||||
if _, err := sourcesOf([]string{"10.10.0.1", "not-an-address"}); err == nil {
|
|
||||||
t.Error("an entry that is not an address was accepted")
|
|
||||||
}
|
|
||||||
inside, err := sourcesOf([]string{"10.10.0.1", "fd00::1", "10.20.0.0/24"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for remote, want := range map[string]bool{
|
|
||||||
"10.10.0.1:1": true,
|
|
||||||
"[::ffff:10.10.0.1]:1": true,
|
|
||||||
"[fd00::1]:1": true,
|
|
||||||
"10.20.0.200:1": true,
|
|
||||||
"10.10.0.2:1": false,
|
|
||||||
"192.168.1.10:1": false,
|
|
||||||
"not-an-address": false,
|
|
||||||
} {
|
|
||||||
if inside.holds(remote) != want {
|
|
||||||
t.Errorf("%s inside the mesh: got %v, want %v", remote, !want, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// What the mesh issues is what is served: the routes in the membership, internal names to the
|
|
||||||
// machines it names (novox/hq ADR 0167).
|
|
||||||
func TestAMembershipIsServedAsIssued(t *testing.T) {
|
|
||||||
held := newTable()
|
|
||||||
took := applyMembership(broker.Membership{
|
|
||||||
Receives: map[string]json.RawMessage{"route": json.RawMessage(`[
|
|
||||||
{"from":"admin","node":"anchor","at":"anchor.internal",
|
|
||||||
"values":{"internal-name":"admin.anchor.internal","port":8080}}]`)},
|
|
||||||
Mesh: []string{"10.10.0.7"},
|
|
||||||
}, held)
|
|
||||||
if !took {
|
|
||||||
t.Fatal("a membership carrying routes was not applied")
|
|
||||||
}
|
|
||||||
if code, _ := askFrom(held, "admin.anchor.internal", "10.10.0.7:1"); code == http.StatusNotFound {
|
|
||||||
t.Error("a machine the membership names was refused the internal-only route")
|
|
||||||
}
|
|
||||||
if code, _ := askFrom(held, "admin.anchor.internal", "10.10.0.9:1"); code != http.StatusNotFound {
|
|
||||||
t.Errorf("a machine the membership does not name was served the internal-only route: %d", code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A membership that says nothing about routes is one from a controller that does not issue them,
|
|
||||||
// and changes nothing: the file stays the source rather than every route being withdrawn.
|
|
||||||
func TestAMembershipWithoutRoutesLeavesTheFileServing(t *testing.T) {
|
|
||||||
held := behind(t, "10.10.0.7")
|
|
||||||
before := held.names()
|
|
||||||
if applyMembership(broker.Membership{Mesh: []string{"10.10.0.7"}}, held) {
|
|
||||||
t.Error("a membership without routes was taken as the source of routes")
|
|
||||||
}
|
|
||||||
if got := held.names(); strings.Join(got, ",") != strings.Join(before, ",") {
|
|
||||||
t.Errorf("a membership without routes changed what is served: %v, was %v", got, before)
|
|
||||||
}
|
|
||||||
if applyMembership(broker.Membership{
|
|
||||||
Receives: map[string]json.RawMessage{"route": json.RawMessage(`[]`)},
|
|
||||||
Mesh: []string{"not-an-address"},
|
|
||||||
}, held) {
|
|
||||||
t.Error("a membership whose mesh cannot be read was applied")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -90,50 +90,6 @@ func TestARouteWithAnInternalNameIsReachableUnderBoth(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A route whose endpoint reaches only the private network carries an internal name and no public
|
|
||||||
// one (novox/hq ADR 0138), and is served under that name rather than skipped as naming nothing —
|
|
||||||
// skipping it left every internal-only module unreachable by name (novox/hq issue 191).
|
|
||||||
func TestARouteWithOnlyAnInternalNameIsServed(t *testing.T) {
|
|
||||||
routes, public, err := routesFrom(write(t, `{"given":[
|
|
||||||
{"from":"app","node":"anchor","at":"anchor.internal",
|
|
||||||
"values":{"internal-name":"App.Anchor.Internal","port":8443,"scheme":"https","insecure":true}}
|
|
||||||
]}`))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if targetOf(routes, "app.anchor.internal") != "https://anchor.internal:8443" {
|
|
||||||
t.Fatalf("the internal-only route is not served: %v", routes)
|
|
||||||
}
|
|
||||||
if len(routes) != 1 {
|
|
||||||
t.Errorf("an internal-only route made hosts it never named: %v", routes)
|
|
||||||
}
|
|
||||||
if len(public) != 0 {
|
|
||||||
t.Errorf("an internal-only route made a name eligible for a public certificate: %v", public)
|
|
||||||
}
|
|
||||||
|
|
||||||
held := newTable()
|
|
||||||
held.set(routes, public)
|
|
||||||
if err := onlyInternalNamesTheMeshSaid(held)(context.Background(), "app.anchor.internal"); err != nil {
|
|
||||||
t.Errorf("the internal authority refused the internal-only route's name: %v", err)
|
|
||||||
}
|
|
||||||
if err := onlyWhatTheMeshSaid(held)(context.Background(), "app.anchor.internal"); err == nil {
|
|
||||||
t.Error("a public certificate was ordered for an internal-only name")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A route with neither name has nothing to be served under, and is still skipped.
|
|
||||||
func TestARouteWithNeitherNameIsSkipped(t *testing.T) {
|
|
||||||
routes, public, err := routesFrom(write(t, `{"given":[
|
|
||||||
{"from":"app","node":"anchor","at":"anchor.internal","values":{"internal-name":" ","port":8080}}
|
|
||||||
]}`))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(routes) != 0 || len(public) != 0 {
|
|
||||||
t.Errorf("a route that named nothing was served: %v %v", routes, public)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A route with no internal-name composed gets no second host — the ordinary case, unchanged.
|
// A route with no internal-name composed gets no second host — the ordinary case, unchanged.
|
||||||
func TestARouteWithNoInternalNameGetsNoAlias(t *testing.T) {
|
func TestARouteWithNoInternalNameGetsNoAlias(t *testing.T) {
|
||||||
routes, _, err := routesFrom(write(t, `{"given":[
|
routes, _, err := routesFrom(write(t, `{"given":[
|
||||||
|
|||||||
@@ -161,15 +161,8 @@ func HolderConsumerFor(node, module string, seat DeclaredSeat) (Consumer, bool)
|
|||||||
Queue: "holders",
|
Queue: "holders",
|
||||||
AckWaitSeconds: 60,
|
AckWaitSeconds: 60,
|
||||||
MaxDeliver: 5,
|
MaxDeliver: 5,
|
||||||
// **One in flight.** A holder works one ask at a time, so the server hands it one at a
|
|
||||||
// time: with the default of many, every ask behind the one being worked was delivered,
|
|
||||||
// left unacknowledged for the length of the work, redelivered after the ack wait, and
|
|
||||||
// after the fifth time dropped — on 2026-10-01 twenty-six of forty-three builds asked in
|
|
||||||
// two minutes were never built, and the queue read as empty (novox/hq issue 186).
|
|
||||||
MaxAckPending: 1,
|
|
||||||
Why: fmt.Sprintf("%s on %s holds %s; it acknowledges after the work is done, so a "+
|
Why: fmt.Sprintf("%s on %s holds %s; it acknowledges after the work is done, so a "+
|
||||||
"crash mid-work redelivers rather than loses; one in flight, so a queue of asks is a "+
|
"crash mid-work redelivers rather than loses", module, node, seat.Name),
|
||||||
"queue and not a race against the ack wait", module, node, seat.Name),
|
|
||||||
}, true
|
}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -153,16 +153,3 @@ func TestANodesDeclarationConsumerIsWhatItsOwnGrantAllows(t *testing.T) {
|
|||||||
has(t, perms.Publish, "$JS.ACK.NODES."+c.Name+".>")
|
has(t, perms.Publish, "$JS.ACK.NODES."+c.Name+".>")
|
||||||
has(t, perms.Subscribe, c.Filters[0])
|
has(t, perms.Subscribe, c.Filters[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
// A holder works one ask at a time, so the server hands it one at a time (novox/hq issue 186):
|
|
||||||
// asks queued behind the one being worked wait in the stream rather than being delivered,
|
|
||||||
// left to expire and dropped after the fifth redelivery.
|
|
||||||
func TestAHoldersWorkerTakesOneAskAtATime(t *testing.T) {
|
|
||||||
c, found := HolderConsumerFor("anchor", "builder", DeclaredSeat{Name: "mesh-build-machine", Accepts: []string{"build"}})
|
|
||||||
if !found {
|
|
||||||
t.Fatal("a seat that accepts work has no worker")
|
|
||||||
}
|
|
||||||
if c.MaxAckPending != 1 {
|
|
||||||
t.Fatalf("the worker may have %d asks in flight; one, so a queue is a queue", c.MaxAckPending)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -144,7 +144,6 @@ func (j *JetStream) EnsureStream(s Stream) error {
|
|||||||
MaxMsgsPerSubject: int64(s.MaxMsgsPerSubject),
|
MaxMsgsPerSubject: int64(s.MaxMsgsPerSubject),
|
||||||
Description: s.Why,
|
Description: s.Why,
|
||||||
}
|
}
|
||||||
want.AllowDirect = s.Direct
|
|
||||||
if s.Retention == RetentionLastPerSubject {
|
if s.Retention == RetentionLastPerSubject {
|
||||||
// Last-per-subject is a limits stream with one message kept per subject, not a
|
// Last-per-subject is a limits stream with one message kept per subject, not a
|
||||||
// retention policy of its own — the state shape, spelled the way the server spells it.
|
// retention policy of its own — the state shape, spelled the way the server spells it.
|
||||||
|
|||||||
@@ -1,143 +0,0 @@
|
|||||||
package broker
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// What the mesh issues an assignment to serve and to reach (novox/hq ADR 0160).
|
|
||||||
//
|
|
||||||
// A module's code names its tools and its events; **where they land is the mesh's to decide**, and
|
|
||||||
// it decided it twice — once in the runtime, once here, by one rule compiled into both. Now the
|
|
||||||
// controller composes a membership for every module on every machine and publishes it to a subject
|
|
||||||
// only that assignment reads; the runtime serves exactly what the membership says, and the account's
|
|
||||||
// grant is the same composition read the other way. The shape issued today is the shape the mesh
|
|
||||||
// already had, so nothing moves when a membership first arrives; only who decides it moves.
|
|
||||||
|
|
||||||
// Membership is one assignment's subjects: what this instance of a module on this machine serves,
|
|
||||||
// and what it may reach.
|
|
||||||
type Membership struct {
|
|
||||||
Node string `json:"node"`
|
|
||||||
Module string `json:"module"`
|
|
||||||
// Serves is every address a tool of this instance answers on. `{tool}` stands for the tool's
|
|
||||||
// own name, which the module knows and the mesh does not need to: the mesh issues the address,
|
|
||||||
// the runtime fills the name. An address with a queue is shared with the module's other
|
|
||||||
// instances, and the bus hands each call to one of them; an address without is this instance's.
|
|
||||||
Serves []Served `json:"serves"`
|
|
||||||
// Seats is every verb of a seat this instance holds, at the subject the seat's callers use.
|
|
||||||
Seats []SeatServed `json:"seats,omitempty"`
|
|
||||||
// Emits is where an event of this module lands; `{event}` stands for the event's name.
|
|
||||||
Emits string `json:"emits"`
|
|
||||||
// Reaches is each tool this module may call, `<module>.<tool>`, to the subjects that reach it:
|
|
||||||
// the first is whichever instance answers, when the mesh issued one; the rest name a machine.
|
|
||||||
Reaches map[string][]string `json:"reaches,omitempty"`
|
|
||||||
// Tools is where this instance answers what it serves — the runtime's one verb of its own.
|
|
||||||
Tools string `json:"tools"`
|
|
||||||
// Receives is what this assignment is given for each requirement it receives, by requirement:
|
|
||||||
// the contributions of every module that asked for it, as the catalogue composed them (novox/hq
|
|
||||||
// ADR 0167). The same list its received file is written from, so the two cannot disagree; a
|
|
||||||
// requirement nobody contributed to is an empty list, never absent. Kept as JSON because the
|
|
||||||
// catalogue owns the shape of a contribution and the bus only carries it.
|
|
||||||
Receives map[string]json.RawMessage `json:"receives,omitempty"`
|
|
||||||
// Mesh is every machine's address on the private network — what a rule saying "from the mesh"
|
|
||||||
// resolves to in the packet filter, issued here from the same list (novox/hq ADR 0167). A
|
|
||||||
// module that must tell the mesh from the world, the route proxy serving an internal name, reads
|
|
||||||
// it here rather than keeping a definition of its own.
|
|
||||||
Mesh []string `json:"mesh,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Served is one address a tool is answered on.
|
|
||||||
type Served struct {
|
|
||||||
Subject string `json:"subject"`
|
|
||||||
Queue string `json:"queue,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// SeatServed is one verb of a held seat, where its callers ask.
|
|
||||||
type SeatServed struct {
|
|
||||||
Seat string `json:"seat"`
|
|
||||||
Verb string `json:"verb"`
|
|
||||||
Subject string `json:"subject"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// MembershipSubject is the one address a runtime derives for itself: where its own membership is
|
|
||||||
// published, from the two names its credential carries. Everything else is in the membership.
|
|
||||||
func MembershipSubject(node, module string) string {
|
|
||||||
return "mesh.assignment." + node + "." + module
|
|
||||||
}
|
|
||||||
|
|
||||||
// Placements is where every module runs, for deciding which instance answers for the module.
|
|
||||||
type Placements struct {
|
|
||||||
// Nodes is each module's machines.
|
|
||||||
Nodes map[string][]string
|
|
||||||
// Interchangeable is each module whose definition says its instances are the same anywhere,
|
|
||||||
// so the module's plain subject is issued to all of them in one queue.
|
|
||||||
Interchangeable map[string]bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// AnswersForTheModule says whether an instance of a module on one machine is issued the module's
|
|
||||||
// plain subject: when it is the only instance, or when the definition says instances are
|
|
||||||
// interchangeable. A stateful module on two machines gets only its machines' subjects, so a call
|
|
||||||
// that names none reaches nothing rather than the wrong store.
|
|
||||||
func (p Placements) AnswersForTheModule(module string) bool {
|
|
||||||
return len(p.Nodes[module]) <= 1 || p.Interchangeable[module]
|
|
||||||
}
|
|
||||||
|
|
||||||
// MembershipFor composes one assignment's membership from what it declared and where everything
|
|
||||||
// runs. The subjects are the ones PermissionsFor grants, derived here once more only until the
|
|
||||||
// grant itself is read from the membership — which is the next step, not this one.
|
|
||||||
func MembershipFor(node string, d Declared, where Placements) Membership {
|
|
||||||
own := "mesh.mod." + d.Module
|
|
||||||
m := Membership{
|
|
||||||
Node: node, Module: d.Module,
|
|
||||||
Emits: own + ".event.{event}",
|
|
||||||
Tools: own + ".tool.tools",
|
|
||||||
}
|
|
||||||
// This machine's address always; the module's when this instance answers for the module.
|
|
||||||
m.Serves = append(m.Serves, Served{Subject: own + ".tool.{tool}." + node})
|
|
||||||
if where.AnswersForTheModule(d.Module) {
|
|
||||||
m.Serves = append(m.Serves, Served{Subject: own + ".tool.{tool}", Queue: "serve." + d.Module})
|
|
||||||
}
|
|
||||||
for _, s := range d.Holds {
|
|
||||||
for _, verb := range s.Serves {
|
|
||||||
m.Seats = append(m.Seats, SeatServed{Seat: s.Name, Verb: verb, Subject: seatToolSubject(s, verb, node)})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(d.Invokes) > 0 {
|
|
||||||
m.Reaches = map[string][]string{}
|
|
||||||
for _, t := range d.Invokes {
|
|
||||||
if t == "*" || strings.HasPrefix(t, "seat:") {
|
|
||||||
continue // every tool, or a role's: addressed by name, not resolved per instance
|
|
||||||
}
|
|
||||||
module, tool, ok := strings.Cut(t, ".")
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var reach []string
|
|
||||||
if where.AnswersForTheModule(module) {
|
|
||||||
reach = append(reach, "mesh.mod."+module+".tool."+tool)
|
|
||||||
}
|
|
||||||
nodes := append([]string{}, where.Nodes[module]...)
|
|
||||||
sort.Strings(nodes)
|
|
||||||
for _, n := range nodes {
|
|
||||||
reach = append(reach, "mesh.mod."+module+".tool."+tool+"."+n)
|
|
||||||
}
|
|
||||||
m.Reaches[t] = reach
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
// PlacementsOf reads where everything runs from the records the bus's accounts are composed from.
|
|
||||||
func PlacementsOf(r Records, interchangeable map[string]bool) Placements {
|
|
||||||
p := Placements{Nodes: map[string][]string{}, Interchangeable: interchangeable}
|
|
||||||
for node, declared := range r.Assigned {
|
|
||||||
for _, d := range declared {
|
|
||||||
p.Nodes[d.Module] = append(p.Nodes[d.Module], node)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, nodes := range p.Nodes {
|
|
||||||
sort.Strings(nodes)
|
|
||||||
}
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
package broker
|
|
||||||
|
|
||||||
import (
|
|
||||||
"reflect"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The mesh issues an assignment's subjects (novox/hq ADR 0160): a module alone on one machine
|
|
||||||
// answers for the module and for its machine; a stateful module on two machines answers only for
|
|
||||||
// each machine; one that says its instances are interchangeable answers for the module everywhere;
|
|
||||||
// a holder serves its seat's verbs; and what a module may reach is resolved the same way.
|
|
||||||
func TestAMembershipIsIssuedFromWhereEverythingRuns(t *testing.T) {
|
|
||||||
records := Records{Assigned: map[string][]Declared{
|
|
||||||
"anchor": {
|
|
||||||
{Module: "postgres", Serves: []string{"query"}, Holds: []Seat{{Name: "mesh-store", Scope: "mesh", Serves: []string{"databases", "query"}}}},
|
|
||||||
{Module: "catalog", Invokes: []string{"postgres.query", "search.find"}},
|
|
||||||
},
|
|
||||||
"home-server": {
|
|
||||||
{Module: "postgres"},
|
|
||||||
{Module: "search"},
|
|
||||||
{Module: "dashboard", Invokes: []string{"postgres.query"}},
|
|
||||||
},
|
|
||||||
"laptop": {{Module: "search"}},
|
|
||||||
}, Interchangeable: map[string]bool{"search": true}}
|
|
||||||
where := PlacementsOf(records, records.Interchangeable)
|
|
||||||
|
|
||||||
pg := MembershipFor("anchor", records.Assigned["anchor"][0], where)
|
|
||||||
if !reflect.DeepEqual(pg.Serves, []Served{{Subject: "mesh.mod.postgres.tool.{tool}.anchor"}}) {
|
|
||||||
t.Fatalf("a stateful module on two machines answers only for its machine: %+v", pg.Serves)
|
|
||||||
}
|
|
||||||
if len(pg.Seats) != 2 || pg.Seats[0].Subject != "mesh.seat.mesh-store.tool.databases" {
|
|
||||||
t.Fatalf("the holder serves the seat's verbs at the seat's subjects: %+v", pg.Seats)
|
|
||||||
}
|
|
||||||
if pg.Emits != "mesh.mod.postgres.event.{event}" || pg.Tools != "mesh.mod.postgres.tool.tools" {
|
|
||||||
t.Fatalf("events and the tools verb: %+v", pg)
|
|
||||||
}
|
|
||||||
|
|
||||||
search := MembershipFor("laptop", records.Assigned["laptop"][0], where)
|
|
||||||
if !reflect.DeepEqual(search.Serves, []Served{
|
|
||||||
{Subject: "mesh.mod.search.tool.{tool}.laptop"},
|
|
||||||
{Subject: "mesh.mod.search.tool.{tool}", Queue: "serve.search"},
|
|
||||||
}) {
|
|
||||||
t.Fatalf("an interchangeable module answers for the module in the queue too: %+v", search.Serves)
|
|
||||||
}
|
|
||||||
|
|
||||||
dashboard := MembershipFor("home-server", records.Assigned["home-server"][2], where)
|
|
||||||
if !reflect.DeepEqual(dashboard.Serves, []Served{
|
|
||||||
{Subject: "mesh.mod.dashboard.tool.{tool}.home-server"},
|
|
||||||
{Subject: "mesh.mod.dashboard.tool.{tool}", Queue: "serve.dashboard"},
|
|
||||||
}) {
|
|
||||||
t.Fatalf("a module alone on one machine answers for the module: %+v", dashboard.Serves)
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(dashboard.Reaches["postgres.query"],
|
|
||||||
[]string{"mesh.mod.postgres.tool.query.anchor", "mesh.mod.postgres.tool.query.home-server"}) {
|
|
||||||
t.Fatalf("reaching a stateful module names each machine and no plain subject: %v", dashboard.Reaches)
|
|
||||||
}
|
|
||||||
catalog := MembershipFor("anchor", records.Assigned["anchor"][1], where)
|
|
||||||
if !reflect.DeepEqual(catalog.Reaches["search.find"],
|
|
||||||
[]string{"mesh.mod.search.tool.find", "mesh.mod.search.tool.find.home-server", "mesh.mod.search.tool.find.laptop"}) {
|
|
||||||
t.Fatalf("reaching an interchangeable module offers the plain subject first: %v", catalog.Reaches)
|
|
||||||
}
|
|
||||||
if MembershipSubject("anchor", "postgres") != "mesh.assignment.anchor.postgres" {
|
|
||||||
t.Fatal("the one subject a runtime derives for itself")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnAccountMayReadItsOwnMembershipAndNoOthers(t *testing.T) {
|
|
||||||
perms, err := PermissionsFor(Principal{Kind: KindModule, Node: "anchor", Module: "postgres", PasswordHash: "x"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
has(t, perms.Subscribe, "mesh.assignment.anchor.postgres")
|
|
||||||
has(t, perms.Publish, "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.anchor.postgres")
|
|
||||||
hasNot(t, perms.Subscribe, "mesh.assignment.>")
|
|
||||||
}
|
|
||||||
@@ -173,10 +173,8 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
switch p.Kind {
|
switch p.Kind {
|
||||||
case KindController:
|
case KindController:
|
||||||
// The controller owns the mesh's own traffic and the streams. It is the only writer of
|
// The controller owns the mesh's own traffic and the streams. It is the only writer of
|
||||||
// stream definitions (design 25 §3), so it alone reaches the JetStream API — and it alone
|
// stream definitions (design 25 §3), so it alone reaches the JetStream API.
|
||||||
// issues memberships (novox/hq ADR 0160), which it publishes into the assignments stream
|
pub = []string{"mesh.control.>", "mesh.node.>", "$JS.API.>"}
|
||||||
// after each push; refused by the server on 2026-10-01 until this line named them.
|
|
||||||
pub = []string{"mesh.control.>", "mesh.node.>", "mesh.assignment.>", "$JS.API.>"}
|
|
||||||
// **And where its consumers deliver.** A push consumer delivers on `_DELIVER.<its name>`,
|
// **And where its consumers deliver.** A push consumer delivers on `_DELIVER.<its name>`,
|
||||||
// and a client bound to it subscribes exactly that; the server refused it for every
|
// and a client bound to it subscribes exactly that; the server refused it for every
|
||||||
// principal the first time one bound a consumer (2026-09-28). Each kind below is granted
|
// principal the first time one bound a consumer (2026-09-28). Each kind below is granted
|
||||||
@@ -300,10 +298,6 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
// away — no other principal may subscribe this namespace, and a caller's authority is
|
// away — no other principal may subscribe this namespace, and a caller's authority is
|
||||||
// still granted per tool, by name, on the publish side.
|
// still granted per tool, by name, on the publish side.
|
||||||
sub = append(sub, own+".tool.>")
|
sub = append(sub, own+".tool.>")
|
||||||
// Its own membership (ADR 0160): the one subject a runtime derives for itself, read
|
|
||||||
// directly from the stream and followed live. Nothing else's.
|
|
||||||
sub = append(sub, MembershipSubject(p.Node, p.Module))
|
|
||||||
pub = append(pub, "$JS.API.DIRECT.GET."+AssignmentsStream+"."+MembershipSubject(p.Node, p.Module))
|
|
||||||
|
|
||||||
// 1b. The tools it calls, if its manifest says it calls any (novox/hq ADR 0152). The same
|
// 1b. The tools it calls, if its manifest says it calls any (novox/hq ADR 0152). The same
|
||||||
// grant a person gets and derived the same way, so "what may this module ask" is
|
// grant a person gets and derived the same way, so "what may this module ask" is
|
||||||
|
|||||||
@@ -47,14 +47,8 @@ type Stream struct {
|
|||||||
// Why is carried into the assertion so an operator reading the server's own state finds the
|
// Why is carried into the assertion so an operator reading the server's own state finds the
|
||||||
// reason there, rather than only in a repository they may not have.
|
// reason there, rather than only in a repository they may not have.
|
||||||
Why string
|
Why string
|
||||||
// Direct lets a client read a subject's last message without a consumer, which is how a
|
|
||||||
// runtime reads its own membership with no JetStream API beyond one request (ADR 0160).
|
|
||||||
Direct bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// AssignmentsStream holds every assignment's membership, the newest per subject.
|
|
||||||
const AssignmentsStream = "ASSIGNMENTS"
|
|
||||||
|
|
||||||
// MeshStreams is the foundation set, in the order a person reads it.
|
// MeshStreams is the foundation set, in the order a person reads it.
|
||||||
//
|
//
|
||||||
// **CONTROL names its subjects rather than taking `mesh.control.>`**, because heartbeats live
|
// **CONTROL names its subjects rather than taking `mesh.control.>`**, because heartbeats live
|
||||||
@@ -84,14 +78,6 @@ func MeshStreams() []Stream {
|
|||||||
Why: "one declaration per node, always the newest; a node that sees sequence n refuses " +
|
Why: "one declaration per node, always the newest; a node that sees sequence n refuses " +
|
||||||
"n-1 by construction (issue 107)",
|
"n-1 by construction (issue 107)",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
Name: AssignmentsStream,
|
|
||||||
Subjects: []string{"mesh.assignment.*.*"},
|
|
||||||
Retention: RetentionLastPerSubject,
|
|
||||||
Direct: true,
|
|
||||||
Why: "one membership per assignment, always the newest: what the mesh issued this module " +
|
|
||||||
"on this machine to serve and to reach (ADR 0160); read directly by the runtime it is for",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
Name: EventsStream,
|
Name: EventsStream,
|
||||||
// A seat's own events ride here too: they are 1:many like any event, and the
|
// A seat's own events ride here too: they are 1:many like any event, and the
|
||||||
|
|||||||
@@ -126,7 +126,6 @@ func TestEachStreamCarriesTheRetentionItsShapeNeeds(t *testing.T) {
|
|||||||
"CONTROL": RetentionWorkQueue,
|
"CONTROL": RetentionWorkQueue,
|
||||||
"NODES": RetentionLastPerSubject,
|
"NODES": RetentionLastPerSubject,
|
||||||
"EVENTS": RetentionLimits,
|
"EVENTS": RetentionLimits,
|
||||||
"ASSIGNMENTS": RetentionLastPerSubject,
|
|
||||||
}
|
}
|
||||||
got := map[string]Retention{}
|
got := map[string]Retention{}
|
||||||
for _, s := range MeshStreams() {
|
for _, s := range MeshStreams() {
|
||||||
|
|||||||
+7
-7
@@ -24,7 +24,7 @@ accounts {
|
|||||||
jetstream: enabled
|
jetstream: enabled
|
||||||
users = [
|
users = [
|
||||||
{ user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: {
|
{ user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: {
|
||||||
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "_INBOX.enrol.>", "mesh.assignment.>", "mesh.control.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.refused"] }
|
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "_INBOX.enrol.>", "mesh.control.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.refused"] }
|
||||||
subscribe: { allow: ["$JS.API.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.gitea.event.pull.merged", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built", "mesh.seat.mesh-controller.tool.>"] }
|
subscribe: { allow: ["$JS.API.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.gitea.event.pull.merged", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built", "mesh.seat.mesh-controller.tool.>"] }
|
||||||
allow_responses: { max: 1, ttl: "1m" }
|
allow_responses: { max: 1, ttl: "1m" }
|
||||||
} }
|
} }
|
||||||
@@ -37,18 +37,18 @@ accounts {
|
|||||||
subscribe: { allow: ["_DELIVER.one", "_DELIVER.one.>", "_INBOX.node.one.>", "mesh.node.one.declare"] }
|
subscribe: { allow: ["_DELIVER.one", "_DELIVER.one.>", "_INBOX.node.one.>", "mesh.node.one.declare"] }
|
||||||
} }
|
} }
|
||||||
{ user: "one.telegram", password: "$2a$11$tttttttttttttttttttttt", permissions: {
|
{ user: "one.telegram", password: "$2a$11$tttttttttttttttttttttt", permissions: {
|
||||||
publish: { allow: ["$JS.ACK.EVENTS.one_telegram.>", "$JS.ACK.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker.>", "$JS.API.CONSUMER.INFO.EVENTS.one_telegram", "$JS.API.CONSUMER.INFO.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_telegram", "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.one.telegram", "mesh.seat.telegram-sender.event.delivered", "mesh.seat.telegram-sender.event.failed"] }
|
publish: { allow: ["$JS.ACK.EVENTS.one_telegram.>", "$JS.ACK.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker.>", "$JS.API.CONSUMER.INFO.EVENTS.one_telegram", "$JS.API.CONSUMER.INFO.SEAT_TELEGRAM_SENDER.SEAT_TELEGRAM_SENDER_worker", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.one_telegram", "mesh.seat.telegram-sender.event.delivered", "mesh.seat.telegram-sender.event.failed"] }
|
||||||
subscribe: { allow: ["_DELIVER.SEAT_TELEGRAM_SENDER_worker", "_DELIVER.SEAT_TELEGRAM_SENDER_worker.>", "_INBOX.one.telegram.>", "mesh.assignment.one.telegram", "mesh.mod.telegram.tool.>", "mesh.seat.telegram-sender.accept.send"] }
|
subscribe: { allow: ["_DELIVER.SEAT_TELEGRAM_SENDER_worker", "_DELIVER.SEAT_TELEGRAM_SENDER_worker.>", "_INBOX.one.telegram.>", "mesh.mod.telegram.tool.>", "mesh.seat.telegram-sender.accept.send"] }
|
||||||
allow_responses: { max: 1, ttl: "1m" }
|
allow_responses: { max: 1, ttl: "1m" }
|
||||||
} }
|
} }
|
||||||
{ user: "two.audit", password: "$2a$11$aaaaaaaaaaaaaaaaaaaaaa", permissions: {
|
{ user: "two.audit", password: "$2a$11$aaaaaaaaaaaaaaaaaaaaaa", permissions: {
|
||||||
publish: { allow: ["$JS.ACK.EVENTS.two_audit.>", "$JS.API.CONSUMER.INFO.EVENTS.two_audit", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.two_audit", "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.two.audit"] }
|
publish: { allow: ["$JS.ACK.EVENTS.two_audit.>", "$JS.API.CONSUMER.INFO.EVENTS.two_audit", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.two_audit"] }
|
||||||
subscribe: { allow: ["_INBOX.two.audit.>", "mesh.assignment.two.audit", "mesh.mod.audit.tool.>", "mesh.mod.shop.event.order.placed"] }
|
subscribe: { allow: ["_INBOX.two.audit.>", "mesh.mod.audit.tool.>", "mesh.mod.shop.event.order.placed"] }
|
||||||
allow_responses: { max: 1, ttl: "1m" }
|
allow_responses: { max: 1, ttl: "1m" }
|
||||||
} }
|
} }
|
||||||
{ user: "two.shop", password: "$2a$11$ssssssssssssssssssssss", permissions: {
|
{ user: "two.shop", password: "$2a$11$ssssssssssssssssssssss", permissions: {
|
||||||
publish: { allow: ["$JS.ACK.EVENTS.two_shop.>", "$JS.API.CONSUMER.INFO.EVENTS.two_shop", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.two_shop", "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.two.shop", "mesh.mod.shop.event.order.placed", "mesh.seat.telegram-sender.accept.send"] }
|
publish: { allow: ["$JS.ACK.EVENTS.two_shop.>", "$JS.API.CONSUMER.INFO.EVENTS.two_shop", "$JS.API.CONSUMER.MSG.NEXT.EVENTS.two_shop", "mesh.mod.shop.event.order.placed", "mesh.seat.telegram-sender.accept.send"] }
|
||||||
subscribe: { allow: ["_INBOX.two.shop.>", "mesh.assignment.two.shop", "mesh.mod.shop.tool.>"] }
|
subscribe: { allow: ["_INBOX.two.shop.>", "mesh.mod.shop.tool.>"] }
|
||||||
allow_responses: { max: 1, ttl: "1m" }
|
allow_responses: { max: 1, ttl: "1m" }
|
||||||
} }
|
} }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -47,9 +47,6 @@ type Records struct {
|
|||||||
Enrolling []string
|
Enrolling []string
|
||||||
// People is each person's name against the tools they may invoke, `*` for an administrator.
|
// People is each person's name against the tools they may invoke, `*` for an administrator.
|
||||||
People map[string][]string
|
People map[string][]string
|
||||||
// Interchangeable is each module whose definition says its instances are the same anywhere
|
|
||||||
// (ADR 0160), which decides whether the module's plain subject is issued to every instance.
|
|
||||||
Interchangeable map[string]bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Users is every user the composed file should contain, in the order it will be written.
|
// Users is every user the composed file should contain, in the order it will be written.
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func reachable() Node {
|
|||||||
func onNetwork(nodes ...string) map[string][]Provider {
|
func onNetwork(nodes ...string) map[string][]Provider {
|
||||||
out := make([]Provider, 0, len(nodes))
|
out := make([]Provider, 0, len(nodes))
|
||||||
for _, n := range nodes {
|
for _, n := range nodes {
|
||||||
out = append(out, Provider{Node: n, At: n + ".internal", Module: "postgres"})
|
out = append(out, Provider{Node: n, At: n + ".internal"})
|
||||||
}
|
}
|
||||||
return map[string][]Provider{"postgres-database": out}
|
return map[string][]Provider{"postgres-database": out}
|
||||||
}
|
}
|
||||||
@@ -99,7 +99,7 @@ func TestSayingWhichOneSettlesIt(t *testing.T) {
|
|||||||
got, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(),
|
got, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(),
|
||||||
World{
|
World{
|
||||||
Offered: onNetwork("anchor", "archive"),
|
Offered: onNetwork("anchor", "archive"),
|
||||||
Pinned: map[string]Chosen{"postgres-database": {Node: "archive", Module: "postgres"}},
|
Pinned: map[string]string{"postgres-database": "archive"},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -115,7 +115,7 @@ func TestBeingPointedAtAMachineThatDoesNotProvideItIsRefused(t *testing.T) {
|
|||||||
_, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(),
|
_, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(),
|
||||||
World{
|
World{
|
||||||
Offered: onNetwork("anchor", "archive"),
|
Offered: onNetwork("anchor", "archive"),
|
||||||
Pinned: map[string]Chosen{"postgres-database": {Node: "somewhere-else", Module: "postgres"}},
|
Pinned: map[string]string{"postgres-database": "somewhere-else"},
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("a machine was silently given a different database from the one chosen")
|
t.Fatal("a machine was silently given a different database from the one chosen")
|
||||||
@@ -131,12 +131,12 @@ func TestOneProviderDoesNotOverruleAChoice(t *testing.T) {
|
|||||||
_, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(),
|
_, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(),
|
||||||
World{
|
World{
|
||||||
Offered: onNetwork("anchor"),
|
Offered: onNetwork("anchor"),
|
||||||
Pinned: map[string]Chosen{"postgres-database": {Node: "archive", Module: "postgres"}},
|
Pinned: map[string]string{"postgres-database": "archive"},
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("the only database was used although another was chosen")
|
t.Fatal("the only database was used although another was chosen")
|
||||||
}
|
}
|
||||||
if !strings.Contains(err.Error(), "only anchor/postgres provides it") {
|
if !strings.Contains(err.Error(), "only anchor provides it") {
|
||||||
t.Fatalf("the refusal does not say what is available: %v", err)
|
t.Fatalf("the refusal does not say what is available: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sort"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Chosen is the provider somebody named for a provision: the module, and the node it runs on. Both,
|
|
||||||
// always (novox/hq #258) — a provision comes from a module, and the same module on two machines is
|
|
||||||
// two answers, so neither half alone says which. Module is empty only on a record made before this
|
|
||||||
// was asked, and such a record is honoured exactly as long as it is unambiguous.
|
|
||||||
type Chosen struct {
|
|
||||||
Node string
|
|
||||||
Module string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c Chosen) String() string {
|
|
||||||
if c.Module == "" {
|
|
||||||
return c.Node
|
|
||||||
}
|
|
||||||
return c.Node + "/" + c.Module
|
|
||||||
}
|
|
||||||
|
|
||||||
// matches is whether this provider is the one chosen.
|
|
||||||
func (c Chosen) matches(p Provider) bool {
|
|
||||||
return p.Node == c.Node && (c.Module == "" || p.Module == c.Module)
|
|
||||||
}
|
|
||||||
|
|
||||||
// among is every offered provider the choice names — one, when the choice is whole.
|
|
||||||
func (c Chosen) among(where []Provider) []Provider {
|
|
||||||
var out []Provider
|
|
||||||
for _, p := range where {
|
|
||||||
if c.matches(p) {
|
|
||||||
out = append(out, p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// nameOf is how a refusal names a provider: the node and the module on it.
|
|
||||||
func nameOf(p Provider) string {
|
|
||||||
return Chosen{Node: p.Node, Module: p.Module}.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// providerNames is every provider named, sorted, for a refusal to list.
|
|
||||||
func providerNames(where []Provider) []string {
|
|
||||||
out := make([]string, 0, len(where))
|
|
||||||
for _, p := range where {
|
|
||||||
out = append(out, nameOf(p))
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// providersHere is which modules in this node's own set offer a provision, sorted.
|
|
||||||
func providersHere(catalogue map[string]Manifest, here func(string) bool, want string) []string {
|
|
||||||
var out []string
|
|
||||||
for name, m := range catalogue {
|
|
||||||
if !here(name) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, o := range m.Offers() {
|
|
||||||
if o == want {
|
|
||||||
out = append(out, name)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// servedByOne is what one provider beside the consumer says a consumer needs to know, or nothing.
|
|
||||||
//
|
|
||||||
// Serving is *whether* a need is created at all when the provider is on this same machine (novox/hq
|
|
||||||
// 04-ISSUES/038's sibling): a need never created is a binding the consumer never gets. The manifest
|
|
||||||
// alone answers that; the values are settled later, with the node's settings.
|
|
||||||
func servedByOne(m Manifest, want string) map[string]any {
|
|
||||||
if _, ok := m.Serves[want]; ok {
|
|
||||||
return ServedOn(m, want, nil)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// sharedByOne is the own secret that provider names as its credential (ADR 0158), or "" when it
|
|
||||||
// gives each consumer its own.
|
|
||||||
func sharedByOne(m Manifest, want string) string {
|
|
||||||
if own, shared := m.SharedCredentialOf(want); shared {
|
|
||||||
return own
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func oneOf(list []string, s string) bool {
|
|
||||||
for _, x := range list {
|
|
||||||
if x == s {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
@@ -241,40 +241,15 @@ func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) {
|
|||||||
// Owner is kept beside the resources because a resource id cannot be split back into its module:
|
// Owner is kept beside the resources because a resource id cannot be split back into its module:
|
||||||
// a module's name may itself contain a dot. What the mesh adds of its own — an opening, the guard —
|
// a module's name may itself contain a dot. What the mesh adds of its own — an opening, the guard —
|
||||||
// has no owner.
|
// has no owner.
|
||||||
//
|
|
||||||
// Received is what each module on the machine is given for each requirement it receives — the same
|
|
||||||
// contributions its received file is written from, kept beside it so the mesh can also issue them
|
|
||||||
// on the bus in the module's membership (novox/hq ADR 0167). By module, then requirement.
|
|
||||||
type Composed struct {
|
type Composed struct {
|
||||||
Resources []map[string]any
|
Resources []map[string]any
|
||||||
Owner map[string]string
|
Owner map[string]string
|
||||||
Received map[string]map[string][]Contribution
|
|
||||||
// LeftOut is every module of this machine's set that was left out of its declaration, and
|
|
||||||
// why (novox/hq ADR 0163, rule 6): a setting stored for it that its definition can no longer
|
|
||||||
// compose. Its held things are kept and its containers untouched — the machine is told so —
|
|
||||||
// and it is told everything else.
|
|
||||||
LeftOut map[string]string
|
|
||||||
}
|
|
||||||
|
|
||||||
// LeftOut is which of this machine's modules a declaration composed with these settings leaves
|
|
||||||
// out, and why (novox/hq ADR 0163, rule 6): each whose stored settings its definition can no longer
|
|
||||||
// compose. Empty when every module composes. The same judgement SetSettings makes before storing.
|
|
||||||
func (r Resolution) LeftOut(settings SettingsBy, adopted bool) map[string]string {
|
|
||||||
out := map[string]string{}
|
|
||||||
for _, m := range r.Modules {
|
|
||||||
if err := JudgeSettings(m, settings[m.Module], adopted); err != nil {
|
|
||||||
out[m.Module] = err.Error()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compose is Declaration with the owner of every resource said.
|
// Compose is Declaration with the owner of every resource said.
|
||||||
func (r Resolution) Compose(with Rendering) (Composed, error) {
|
func (r Resolution) Compose(with Rendering) (Composed, error) {
|
||||||
owner := map[string]string{}
|
owner := map[string]string{}
|
||||||
received := map[string]map[string][]Contribution{}
|
resources, err := r.compose(with, owner)
|
||||||
leftOut := map[string]string{}
|
|
||||||
resources, err := r.compose(with, owner, received, leftOut)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Composed{}, err
|
return Composed{}, err
|
||||||
}
|
}
|
||||||
@@ -286,7 +261,7 @@ func (r Resolution) Compose(with Rendering) (Composed, error) {
|
|||||||
"sealed": with.BusMembership, "mode": "0600",
|
"sealed": with.BusMembership, "mode": "0600",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return Composed{Resources: resources, Owner: owner, Received: received, LeftOut: leftOut}, nil
|
return Composed{Resources: resources, Owner: owner}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// BusMembershipID names the resource carrying a machine's membership for the new bus, and
|
// BusMembershipID names the resource carrying a machine's membership for the new bus, and
|
||||||
@@ -295,26 +270,7 @@ func BusMembershipID() string { return "bus-membership" }
|
|||||||
|
|
||||||
const BusMembershipPath = "/var/lib/mesh/membership-next.json"
|
const BusMembershipPath = "/var/lib/mesh/membership-next.json"
|
||||||
|
|
||||||
func (r Resolution) compose(with Rendering, owner map[string]string,
|
func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[string]any, error) {
|
||||||
received map[string]map[string][]Contribution, leftOut map[string]string) ([]map[string]any, error) {
|
|
||||||
// **A setting is judged where it is stored, and an impossible one costs a module, not a
|
|
||||||
// machine** (novox/hq ADR 0163, rule 6). A definition that moved under a stored setting makes
|
|
||||||
// this module uncomposable; it is left out of the declaration — its held things kept, its
|
|
||||||
// containers untouched, the machine told so by name — and the machine is told everything else.
|
|
||||||
// Before placing, because a placement is a setting too.
|
|
||||||
left := r.LeftOut(with.Settings, with.Adopted)
|
|
||||||
kept := make([]Manifest, 0, len(r.Modules))
|
|
||||||
for _, m := range r.Modules {
|
|
||||||
if why, isLeft := left[m.Module]; isLeft {
|
|
||||||
if leftOut != nil {
|
|
||||||
leftOut[m.Module] = why
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
kept = append(kept, m)
|
|
||||||
}
|
|
||||||
r.Modules = kept
|
|
||||||
|
|
||||||
// Every manifest is placed first (novox/hq ADR 0112): the maps naming where its bindings,
|
// Every manifest is placed first (novox/hq ADR 0112): the maps naming where its bindings,
|
||||||
// credentials and contributions land are resolved against this node's directories, so every
|
// credentials and contributions land are resolved against this node's directories, so every
|
||||||
// reader below — the binding files, the sealed secrets, the grant paths a contribution
|
// reader below — the binding files, the sealed secrets, the grant paths a contribution
|
||||||
@@ -640,12 +596,6 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
first = append(first, file)
|
first = append(first, file)
|
||||||
if received[m.Module] == nil {
|
|
||||||
received[m.Module] = map[string][]Contribution{}
|
|
||||||
}
|
|
||||||
// Empty rather than absent when nobody contributed, for the reason the file is
|
|
||||||
// written empty: "nothing asked" and "never told" want different responses.
|
|
||||||
received[m.Module][to] = append([]Contribution{}, given[to]...)
|
|
||||||
}
|
}
|
||||||
if m.Keeps != "" && with.Kept != nil {
|
if m.Keeps != "" && with.Kept != nil {
|
||||||
file, err := keptFile(m.Keeps, with.Kept)
|
file, err := keptFile(m.Keeps, with.Kept)
|
||||||
@@ -743,10 +693,6 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
|||||||
// Which of this module's resources its preparation runs before, if it prepares anything.
|
// Which of this module's resources its preparation runs before, if it prepares anything.
|
||||||
prepareBefore := preparationTarget(m)
|
prepareBefore := preparationTarget(m)
|
||||||
|
|
||||||
// Which found networks this machine's setting keeps for each of its containers (novox/hq
|
|
||||||
// ADR 0163, rule 4); judged above, so an invalid one is not here.
|
|
||||||
keptNetworks, _ := KeptNetworks(m, with.Settings[m.Module], with.Adopted)
|
|
||||||
|
|
||||||
for _, unsettled := range resources {
|
for _, unsettled := range resources {
|
||||||
resource, err := ApplySettings(unsettled, with.Settings[m.Module])
|
resource, err := ApplySettings(unsettled, with.Settings[m.Module])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -756,16 +702,6 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
|||||||
for k, v := range resource {
|
for k, v := range resource {
|
||||||
copied[k] = v
|
copied[k] = v
|
||||||
}
|
}
|
||||||
if networks, keeps := keptNetworks[fmt.Sprint(copied["id"])]; keeps {
|
|
||||||
// The container also joins the found network the setting names, so a neighbour
|
|
||||||
// that resolves it there keeps resolving it. Passed to the host as its own field,
|
|
||||||
// which it joins after the container is made.
|
|
||||||
joins := make([]any, 0, len(networks))
|
|
||||||
for _, n := range networks {
|
|
||||||
joins = append(joins, n)
|
|
||||||
}
|
|
||||||
copied["networks"] = joins
|
|
||||||
}
|
|
||||||
if err := refuseSecretsInEnvironment(copied, secretFiles, m.Module); err != nil {
|
if err := refuseSecretsInEnvironment(copied, secretFiles, m.Module); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -1011,15 +947,8 @@ func (r Resolution) filtersHere() string {
|
|||||||
// computed for this machine, and each module's per-node exposure. The same answer whether the node
|
// computed for this machine, and each module's per-node exposure. The same answer whether the node
|
||||||
// is adopted or converged — the one loads it as a filter, the other declares it as openings.
|
// is adopted or converged — the one loads it as a filter, the other declares it as openings.
|
||||||
func (r Resolution) Rules(with Rendering) ([]Rule, error) {
|
func (r Resolution) Rules(with Rendering) ([]Rule, error) {
|
||||||
// A module whose settings cannot compose is left out of the declaration (novox/hq ADR 0163,
|
|
||||||
// rule 6), and out of the filter with it: nothing of it is declared, so nothing of it is let
|
|
||||||
// through.
|
|
||||||
left := r.LeftOut(with.Settings, with.Adopted)
|
|
||||||
exposure := map[string]map[int]string{}
|
exposure := map[string]map[int]string{}
|
||||||
for _, m := range r.Modules {
|
for _, m := range r.Modules {
|
||||||
if _, isLeft := left[m.Module]; isLeft {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
e, err := Exposure(m, with.Settings[m.Module])
|
e, err := Exposure(m, with.Settings[m.Module])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -444,18 +444,6 @@ func AsNftables(rules []Rule, mesh []string, outward bool, foundation []int,
|
|||||||
b.WriteString("\t\t# this machine's own guests reaching outward: not a port opened to anybody\n")
|
b.WriteString("\t\t# this machine's own guests reaching outward: not a port opened to anybody\n")
|
||||||
b.WriteString(fmt.Sprintf("\t\tiifname != { %s } accept\n", inward))
|
b.WriteString(fmt.Sprintf("\t\tiifname != { %s } accept\n", inward))
|
||||||
}
|
}
|
||||||
// **The mesh passing through, not arriving.** A machine the mesh routes through — the hub, for
|
|
||||||
// every path between machines that are not co-located (novox/hq ADR 0007) — relays a packet that
|
|
||||||
// came in on the tunnel and leaves on it again, addressed to another machine of the mesh. That is
|
|
||||||
// no port of this machine's: the machine it is for filters it against its own rules. Without
|
|
||||||
// this, the chain below judged a relayed packet by this machine's own published ports, so two
|
|
||||||
// machines behind the hub reached each other only on ports the hub happened to publish for itself
|
|
||||||
// (novox/hq issue 196). In and out on the tunnel both: a packet off the tunnel for this machine's
|
|
||||||
// own containers leaves by a bridge, and still meets the rules below.
|
|
||||||
if tunnel != "" {
|
|
||||||
b.WriteString("\t\t# the mesh passing through to another of its machines, which filters it itself\n")
|
|
||||||
b.WriteString(fmt.Sprintf("\t\tiifname %q oifname %q accept\n", tunnel, tunnel))
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(rules) > 0 {
|
if len(rules) > 0 {
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
|
|||||||
@@ -887,34 +887,3 @@ func TestAPublicPortNeedsNoGuestLine(t *testing.T) {
|
|||||||
t.Fatalf("a public port was given a guest line it does not need:\n%s", nft)
|
t.Fatalf("a public port was given a guest line it does not need:\n%s", nft)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// **The hub relays the mesh** (novox/hq ADR 0007, issue 196). Two machines that are not co-located
|
|
||||||
// reach each other through the hub, so the hub forwards a packet that arrives on the tunnel and
|
|
||||||
// leaves on it. The forward chain judged that packet by the hub's own published ports, and two
|
|
||||||
// machines behind the hub reached each other only on the ports the hub happened to publish.
|
|
||||||
//
|
|
||||||
// Measured: from one home machine to another through the hub, 17 of 55 ports answered, and they
|
|
||||||
// were exactly the hub's own; the SYN for the rest never left the hub.
|
|
||||||
func TestTheMeshPassingThroughIsRelayedNotJudgedAsThisMachines(t *testing.T) {
|
|
||||||
nft := AsNftables(nil, []string{"10.42.0.1", "10.42.0.2"}, false, nil, []string{"eth0"}, "mesh0")
|
|
||||||
relay := `iifname "mesh0" oifname "mesh0" accept`
|
|
||||||
if !strings.Contains(chainBody(t, nft, "forward"), relay) {
|
|
||||||
t.Errorf("the forward chain does not relay the mesh through this machine:\n%s", chainBody(t, nft, "forward"))
|
|
||||||
}
|
|
||||||
// Relaying is not receiving: nothing in the input chain opens because of it.
|
|
||||||
if strings.Contains(chainBody(t, nft, "input"), "oifname") {
|
|
||||||
t.Errorf("the input chain names an outgoing interface, which no packet for this machine has:\n%s",
|
|
||||||
chainBody(t, nft, "input"))
|
|
||||||
}
|
|
||||||
// And off the tunnel into this machine's own containers is still judged: the tunnel is not
|
|
||||||
// accepted wholesale, only in and out on it.
|
|
||||||
if strings.Contains(chainBody(t, nft, "forward"), `iifname "mesh0" accept`) {
|
|
||||||
t.Errorf("the forward chain accepts everything off the tunnel:\n%s", chainBody(t, nft, "forward"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// A machine with no tunnel relays nothing, and names no interface it does not have.
|
|
||||||
alone := AsNftables(nil, nil, false, nil, []string{"eth0"}, "")
|
|
||||||
if strings.Contains(alone, "oifname") {
|
|
||||||
t.Errorf("a machine with no tunnel was given a relay rule:\n%s", alone)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -107,27 +107,6 @@ func TestCanHoldJudgesClaimScopeAndWhatTheSeatDelivers(t *testing.T) {
|
|||||||
if err := CanHold(cannotAnswer, seat); err == nil || !strings.Contains(err.Error(), `does not provide "mesh-bus"`) {
|
if err := CanHold(cannotAnswer, seat); err == nil || !strings.Contains(err.Error(), `does not provide "mesh-bus"`) {
|
||||||
t.Fatalf("a holder that cannot answer for the seat was allowed: %v", err)
|
t.Fatalf("a holder that cannot answer for the seat was allowed: %v", err)
|
||||||
}
|
}
|
||||||
// A holder's own tools need not be the seat's verbs: the claim may name what it serves for the
|
|
||||||
// role (ADR 0160), and then only those count — and only the seat's verbs may be named.
|
|
||||||
promising := seat
|
|
||||||
promising.Serves = []Verb{{Name: "databases"}, {Name: "query"}}
|
|
||||||
engine := newBroker()
|
|
||||||
engine.Tools = []string{"engine_list_databases", "engine_query"}
|
|
||||||
if err := CanHold(engine, promising); err == nil || !strings.Contains(err.Error(), "does not serve databases, query") {
|
|
||||||
t.Fatalf("a holder whose tools are not the seat's verbs was allowed without saying what it serves: %v", err)
|
|
||||||
}
|
|
||||||
engine.Claims[0].Serves = []string{"databases", "query"}
|
|
||||||
if err := CanHold(engine, promising); err != nil {
|
|
||||||
t.Fatalf("a claim naming the seat's verbs was refused: %v", err)
|
|
||||||
}
|
|
||||||
engine.Claims[0].Serves = []string{"databases"}
|
|
||||||
if err := CanHold(engine, promising); err == nil || !strings.Contains(err.Error(), "does not serve query") {
|
|
||||||
t.Fatalf("a claim naming half the verbs was allowed: %v", err)
|
|
||||||
}
|
|
||||||
engine.Claims[0].Serves = []string{"databases", "query", "engine_query"}
|
|
||||||
if err := CanHold(engine, promising); err == nil || !strings.Contains(err.Error(), "does not promise") {
|
|
||||||
t.Fatalf("a claim naming a verb the seat never promised was allowed: %v", err)
|
|
||||||
}
|
|
||||||
// And the judgement follows the store's row, not a compiled copy.
|
// And the judgement follows the store's row, not a compiled copy.
|
||||||
busSeatDelivering(t, "amqp")
|
busSeatDelivering(t, "amqp")
|
||||||
seat, _ = SeatNamed("mesh-broker")
|
seat, _ = SeatNamed("mesh-broker")
|
||||||
|
|||||||
@@ -102,11 +102,8 @@ func accountHomeOf(account, home string) string {
|
|||||||
func machineInto(resource map[string]any, facts map[string]string, module string) error {
|
func machineInto(resource map[string]any, facts map[string]string, module string) error {
|
||||||
// Content, and now the path and owner too: a module that writes into a person's home names it
|
// Content, and now the path and owner too: a module that writes into a person's home names it
|
||||||
// with ${machine:account-home} and ${machine:account}, which it cannot know until assigned
|
// with ${machine:account-home} and ${machine:account}, which it cannot know until assigned
|
||||||
// (novox/hq to-be 29), the same reason its content names ${machine:address}. And the name a
|
// (novox/hq to-be 29), the same reason its content names ${machine:address}.
|
||||||
// `user` shape sets the login shell of, and the user a user-scoped unit or a process runs as:
|
for _, field := range []string{"path", "owner", "content"} {
|
||||||
// the shell module makes the operator's account its holder's login shell, and the desktop's
|
|
||||||
// watchers run as that account (novox/hq ADR 0176, ADR 0177) — neither can name the person.
|
|
||||||
for _, field := range []string{"path", "owner", "content", "name", "user"} {
|
|
||||||
s, ok := resource[field].(string)
|
s, ok := resource[field].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -52,22 +52,6 @@ type Claim struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
// Scope defaults to the node, which is where nearly everything singular is singular.
|
// Scope defaults to the node, which is where nearly everything singular is singular.
|
||||||
Scope string `json:"scope,omitempty"`
|
Scope string `json:"scope,omitempty"`
|
||||||
// Serves names the seat's verbs this module implements for the role, when its own tools are
|
|
||||||
// not the seat's (novox/hq ADR 0159, 0160): the store's `databases` is not postgres's
|
|
||||||
// `postgres_list_databases`, and a holder may well serve both. The runtime serves an
|
|
||||||
// implementation registered under the seat's name on the seat's subjects. Absent, the
|
|
||||||
// module's own `tools` must list every verb the seat promises, which is how a module named
|
|
||||||
// like its seat — the catalogue, the records — says they are one and the same.
|
|
||||||
Serves []string `json:"serves,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServesFor is what this claim offers a seat's protocol: the verbs it names, else the module's
|
|
||||||
// own tools.
|
|
||||||
func (c Claim) ServesFor(m Manifest) []string {
|
|
||||||
if len(c.Serves) > 0 {
|
|
||||||
return c.Serves
|
|
||||||
}
|
|
||||||
return m.Tools
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// At is this claim's scope, with the default applied.
|
// At is this claim's scope, with the default applied.
|
||||||
@@ -309,12 +293,6 @@ type Manifest struct {
|
|||||||
// module claiming a seat answers what that seat's protocol promises (novox/hq ADR 0118).
|
// module claiming a seat answers what that seat's protocol promises (novox/hq ADR 0118).
|
||||||
Tools []string `json:"tools,omitempty"`
|
Tools []string `json:"tools,omitempty"`
|
||||||
|
|
||||||
// Instances says whether this module's instances are the same anywhere — `interchangeable` —
|
|
||||||
// so a call that names no machine may be answered by any of them (novox/hq ADR 0160). A fact
|
|
||||||
// about the software, not about the bus: a stateless web tool says it; a database does not,
|
|
||||||
// and its instances are then each addressed by machine, never confused for one another.
|
|
||||||
Instances string `json:"instances,omitempty"`
|
|
||||||
|
|
||||||
// Invokes are the tools this module calls, each `<module>.<tool>` or a role's `seat:<seat>.<verb>`,
|
// Invokes are the tools this module calls, each `<module>.<tool>` or a role's `seat:<seat>.<verb>`,
|
||||||
// or the single entry `*` for every tool on the mesh (novox/hq ADR 0152, ADR 0154).
|
// or the single entry `*` for every tool on the mesh (novox/hq ADR 0152, ADR 0154).
|
||||||
//
|
//
|
||||||
@@ -1195,11 +1173,6 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
|||||||
m.Module, r))
|
m.Module, r))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if m.Instances != "" && m.Instances != InstancesInterchangeable {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s says its instances are %q; the one word is %q, for a module that is the same on every machine",
|
|
||||||
m.Module, m.Instances, InstancesInterchangeable))
|
|
||||||
}
|
|
||||||
for _, offer := range m.Provides {
|
for _, offer := range m.Provides {
|
||||||
p := offer.Name
|
p := offer.Name
|
||||||
if !name.MatchString(p) {
|
if !name.MatchString(p) {
|
||||||
@@ -1667,7 +1640,6 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
|||||||
problems = append(problems, m.undeclaredMounts()...)
|
problems = append(problems, m.undeclaredMounts()...)
|
||||||
problems = append(problems, m.unknownDirRefs()...)
|
problems = append(problems, m.unknownDirRefs()...)
|
||||||
problems = append(problems, m.unknownAccessRefs()...)
|
problems = append(problems, m.unknownAccessRefs()...)
|
||||||
problems = append(problems, m.jailProblems()...)
|
|
||||||
|
|
||||||
for i, r := range m.Resources {
|
for i, r := range m.Resources {
|
||||||
id, _ := r["id"].(string)
|
id, _ := r["id"].(string)
|
||||||
@@ -1770,46 +1742,6 @@ func (m Manifest) MachineSide(port int) (at int, mayAssign bool) {
|
|||||||
var facilitiesOf = map[string][]string{
|
var facilitiesOf = map[string][]string{
|
||||||
// Both spellings: /var/run is a link to /run on every machine the mesh runs on.
|
// Both spellings: /var/run is a link to /run on every machine the mesh runs on.
|
||||||
"container-runtime": {"/var/run/docker.sock", "/run/docker.sock"},
|
"container-runtime": {"/var/run/docker.sock", "/run/docker.sock"},
|
||||||
// The virtualisation daemon's socket, for the lab (novox/hq ADR 0172): it raises machines there.
|
|
||||||
"virtualisation": {"/var/lib/incus/unix.socket"},
|
|
||||||
}
|
|
||||||
|
|
||||||
// jailProblems is every jail this module declares that the machine's intrusion prevention would
|
|
||||||
// refuse (novox/hq ADR 0179).
|
|
||||||
//
|
|
||||||
// **Because one bad pattern stops every jail, not its own.** fail2ban expands `<HOST>` into a named
|
|
||||||
// capture group, so a pattern naming it twice is a duplicate group name, and the daemon refuses the
|
|
||||||
// whole configuration and exits — the machine keeps no bans at all, for any jail, including the one
|
|
||||||
// watching its ssh. Caught live on the control node the day this was built, where a proxy's pattern
|
|
||||||
// matched two shapes of refusal in one line. A pattern matches one shape; several shapes are several
|
|
||||||
// patterns, one per line, as fail2ban's own filters are written.
|
|
||||||
func (m Manifest) jailProblems() []string {
|
|
||||||
var problems []string
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for _, j := range m.Jails {
|
|
||||||
switch {
|
|
||||||
case strings.TrimSpace(j.Name) == "":
|
|
||||||
problems = append(problems, m.Module+" declares a jail with no name")
|
|
||||||
case seen[j.Name]:
|
|
||||||
problems = append(problems, m.Module+" declares two jails called "+strconv.Quote(j.Name))
|
|
||||||
}
|
|
||||||
seen[j.Name] = true
|
|
||||||
if strings.TrimSpace(j.Failregex) == "" {
|
|
||||||
problems = append(problems, m.Module+"'s jail "+strconv.Quote(j.Name)+" says nothing a failed attempt looks like")
|
|
||||||
}
|
|
||||||
for _, line := range strings.Split(j.Failregex, "\n") {
|
|
||||||
if strings.TrimSpace(line) == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if n := strings.Count(line, "<HOST>"); n > 1 {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s's jail %s names <HOST> %d times in one pattern; "+
|
|
||||||
"fail2ban reads it as one capture group and refuses the whole configuration, so the machine "+
|
|
||||||
"keeps no bans at all — write one pattern per shape, each naming <HOST> once",
|
|
||||||
m.Module, strconv.Quote(j.Name), n))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// undeclaredMounts is every bind-mount source no declaration covers — see the check above.
|
// undeclaredMounts is every bind-mount source no declaration covers — see the check above.
|
||||||
@@ -1851,12 +1783,6 @@ func (m Manifest) undeclaredMounts() []string {
|
|||||||
claim(p)
|
claim(p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The file a filter module's rule set is written to is declared by `filtering.into`: the mesh
|
|
||||||
// writes it, the module loads it, and the module's runtime may read it back to reload the
|
|
||||||
// mesh's own table (novox/hq ADR 0170).
|
|
||||||
if m.Filtering != nil {
|
|
||||||
claim(m.Filtering.Into)
|
|
||||||
}
|
|
||||||
// Under a declared directory is declared: a module that says where its data lives has said so
|
// Under a declared directory is declared: a module that says where its data lives has said so
|
||||||
// for what it puts inside.
|
// for what it puts inside.
|
||||||
covers := func(path string) bool {
|
covers := func(path string) bool {
|
||||||
@@ -2034,7 +1960,3 @@ func (o OwnSecrets) Paths() map[string]string {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// InstancesInterchangeable is the one value of a definition's `instances`: the module is the same
|
|
||||||
// on every machine, so any instance may answer for the module.
|
|
||||||
const InstancesInterchangeable = "interchangeable"
|
|
||||||
|
|||||||
@@ -98,13 +98,3 @@ func TestAMountOfABoundFactIsAccepted(t *testing.T) {
|
|||||||
t.Fatalf("a mount of the file the mesh writes a binding to was refused: %v", err)
|
t.Fatalf("a mount of the file the mesh writes a binding to was refused: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The file a filter module's rule set is written to is declared by `filtering.into` (novox/hq ADR
|
|
||||||
// 0169): the module's runtime mounts it to reload the mesh's own table, and nothing else declares it.
|
|
||||||
func TestAMountOfTheFilterFileIsDeclaredByFilteringInto(t *testing.T) {
|
|
||||||
_, err := ParseManifest([]byte(`{"module":"nftables","filtering":{"into":"/etc/nftables.conf"},` +
|
|
||||||
`"resources":[` + strings.Replace(aContainerMounting, "%s", "/etc/nftables.conf", 1) + `]}`))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("a filter module mounting its own filter file was refused: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A `user` shape and a user-scoped unit name the operator account the way a home file does
|
|
||||||
// (novox/hq ADR 0176, ADR 0177): with ${machine:account}, resolved when the module is assigned.
|
|
||||||
func TestAUserShapeAndAUserScopedUnitNameTheAccount(t *testing.T) {
|
|
||||||
facts := map[string]string{"account": "ops", "account-home": "/home/ops"}
|
|
||||||
login := map[string]any{"type": "user", "id": "login", "name": "${machine:account}", "shell": "/usr/bin/zsh"}
|
|
||||||
if err := machineInto(login, facts, "zsh"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if login["name"] != "ops" {
|
|
||||||
t.Fatalf("the user shape did not learn the account: %v", login["name"])
|
|
||||||
}
|
|
||||||
watcher := map[string]any{"type": "service", "id": "watcher", "unit": "i3-reload-watcher.service",
|
|
||||||
"scope": "user", "user": "${machine:account}"}
|
|
||||||
if err := machineInto(watcher, facts, "i3"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if watcher["user"] != "ops" {
|
|
||||||
t.Fatalf("the user-scoped unit did not learn the account: %v", watcher["user"])
|
|
||||||
}
|
|
||||||
// A machine with no operator account refuses rather than writing the literal.
|
|
||||||
err := machineInto(map[string]any{"type": "user", "id": "login", "name": "${machine:account}"},
|
|
||||||
map[string]string{"address": "10.0.0.1"}, "zsh")
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "${machine:account}") {
|
|
||||||
t.Fatalf("a user shape on a machine with no account was not refused by name: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The service manager is a seat of the mesh's own with the unit verbs as its contract (novox/hq
|
|
||||||
// ADR 0177): every verb described, with a schema, taking a scope.
|
|
||||||
func TestTheServiceManagerSeatServesTheUnitVerbs(t *testing.T) {
|
|
||||||
seat, ok := SeatNamed("node-service-manager")
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("node-service-manager is not a seat the mesh defines")
|
|
||||||
}
|
|
||||||
if seat.Scope != ScopeNode {
|
|
||||||
t.Fatalf("the service manager is a role each machine has once, and the seat is %s-scoped", seat.Scope)
|
|
||||||
}
|
|
||||||
want := []string{"units", "status", "start", "stop", "restart", "enable", "disable", "journal"}
|
|
||||||
var got []string
|
|
||||||
for _, v := range seat.Serves {
|
|
||||||
got = append(got, v.Name)
|
|
||||||
if v.Description == "" || v.Input == nil {
|
|
||||||
t.Fatalf("%s is promised without a description or a schema", v.Name)
|
|
||||||
}
|
|
||||||
props, _ := v.Input["properties"].(map[string]any)
|
|
||||||
if _, has := props["scope"]; !has {
|
|
||||||
t.Fatalf("%s takes no scope, and a user unit could not be asked for", v.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
|
||||||
t.Fatalf("the seat serves %v, not %v", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Two modules on one node both provide acme-ca — public-acme (Let's Encrypt) and step-ca (the
|
|
||||||
// mesh's own authority) on novox — and a route-proxy elsewhere must get the public one (novox/hq
|
|
||||||
// #258). A pin names the module as well as the node, so that it can say which.
|
|
||||||
|
|
||||||
func issuerShelf() map[string]Manifest {
|
|
||||||
return shelf(
|
|
||||||
Manifest{Module: "public-acme", Version: "1", Provides: FromAnywhere("acme-ca"),
|
|
||||||
Serves: map[string]map[string]any{"acme-ca": {"at": "acme-v02.api.letsencrypt.org"}}},
|
|
||||||
Manifest{Module: "step-ca", Version: "1", Provides: FromAnywhere("acme-ca"),
|
|
||||||
Serves: map[string]map[string]any{"acme-ca": {"at": "novox.internal"}}},
|
|
||||||
Manifest{Module: "route-proxy", Version: "1", Requires: []string{"acme-ca"}},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func twoIssuersOnOneNode() map[string][]Provider {
|
|
||||||
return map[string][]Provider{"acme-ca": {
|
|
||||||
{Node: "novox", At: "novox.internal", Module: "public-acme", Serves: map[string]any{"at": "acme-v02.api.letsencrypt.org"}},
|
|
||||||
{Node: "novox", At: "novox.internal", Module: "step-ca", Serves: map[string]any{"at": "novox.internal"}},
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTwoProvidersOnOneNodeAreRefusedWithBothNamed(t *testing.T) {
|
|
||||||
// The refusal must name the pair, because a node alone cannot tell them apart.
|
|
||||||
_, err := Resolve(issuerShelf(), []string{"route-proxy"}, reachable(),
|
|
||||||
World{Offered: twoIssuersOnOneNode()})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("two providers on one node were resolved by picking")
|
|
||||||
}
|
|
||||||
for _, want := range []string{"novox/public-acme", "novox/step-ca", "<node> <module>"} {
|
|
||||||
if !strings.Contains(err.Error(), want) {
|
|
||||||
t.Fatalf("the refusal does not say %q: %v", want, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAPinNamesTheModule(t *testing.T) {
|
|
||||||
got, err := Resolve(issuerShelf(), []string{"route-proxy"}, reachable(),
|
|
||||||
World{Offered: twoIssuersOnOneNode(),
|
|
||||||
Pinned: map[string]Chosen{"acme-ca": {Node: "novox", Module: "public-acme"}}})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(got.Needs) != 1 || got.Needs[0].From != "novox" || got.Needs[0].Serves["at"] != "acme-v02.api.letsencrypt.org" {
|
|
||||||
t.Fatalf("the named module was not the one taken: %+v", got.Needs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestARecordNamingOnlyTheNodeIsRefusedWhenThatNodeAnswersTwice(t *testing.T) {
|
|
||||||
// A pin from before the module was asked for. It once took the last one listed — a coin flip.
|
|
||||||
_, err := Resolve(issuerShelf(), []string{"route-proxy"}, reachable(),
|
|
||||||
World{Offered: twoIssuersOnOneNode(), Pinned: map[string]Chosen{"acme-ca": {Node: "novox"}}})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("a node that answers twice was resolved by picking")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "provides it 2 times") || !strings.Contains(err.Error(), "pin workstation acme-ca novox <module>") {
|
|
||||||
t.Fatalf("the refusal does not ask for the module: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAPinNamingAModuleThatDoesNotProvideItIsRefused(t *testing.T) {
|
|
||||||
_, err := Resolve(issuerShelf(), []string{"route-proxy"}, reachable(),
|
|
||||||
World{Offered: twoIssuersOnOneNode(), Pinned: map[string]Chosen{"acme-ca": {Node: "novox", Module: "gitea"}}})
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "novox/gitea does not provide it") {
|
|
||||||
t.Fatalf("a module that does not provide it was not refused by name: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTwoProvidersBesideTheConsumerAreRefusedUntilOneIsNamed(t *testing.T) {
|
|
||||||
// The same ambiguity on the consumer's own machine. This was settled by a map walk — random,
|
|
||||||
// per plan — which is how novox's own proxy got its issuer.
|
|
||||||
_, err := Resolve(issuerShelf(), []string{"route-proxy", "public-acme", "step-ca"}, reachable(), World{})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("two providers beside the consumer were resolved by picking")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "workstation provides \"acme-ca\" 2 times") || !strings.Contains(err.Error(), "public-acme, step-ca") {
|
|
||||||
t.Fatalf("the refusal does not list them: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAPinSettlesTwoProvidersBesideTheConsumer(t *testing.T) {
|
|
||||||
got, err := Resolve(issuerShelf(), []string{"route-proxy", "public-acme", "step-ca"}, reachable(),
|
|
||||||
World{Pinned: map[string]Chosen{"acme-ca": {Node: "workstation", Module: "public-acme"}}})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var found bool
|
|
||||||
for _, n := range got.Needs {
|
|
||||||
if n.Name == "acme-ca" {
|
|
||||||
found = true
|
|
||||||
if n.Serves["at"] != "acme-v02.api.letsencrypt.org" {
|
|
||||||
t.Fatalf("the named module was not the one taken: %+v", n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
t.Fatalf("no need for acme-ca was created: %+v", got.Needs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTheFirstPassDoesNotRefuseTwoProvidersBesideTheConsumer(t *testing.T) {
|
|
||||||
// The first pass answers only what a node offers. Refused there, the node vanishes from every
|
|
||||||
// other node's world — and the whole mesh loses its vault for an ambiguity one machine has to
|
|
||||||
// settle. The second pass is where it is refused, and the test above proves it is.
|
|
||||||
if _, err := Resolve(issuerShelf(), []string{"route-proxy", "public-acme", "step-ca"}, reachable(),
|
|
||||||
World{Unchecked: true}); err != nil {
|
|
||||||
t.Fatalf("the first pass refused what only the second may: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -75,26 +75,17 @@ func TestAnAssignmentPlacesDirectoriesAndAccesses(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// An access declared by id and placed by nobody resolves to nowhere, and that is refused with the
|
// An access declared by id and placed by nobody resolves to nowhere, and that is refused with the
|
||||||
// setting to write — not mounted as the literal, not skipped. The refusal costs the module its
|
// setting to write — not mounted as the literal, not skipped.
|
||||||
// place in the declaration, not the machine its declaration (novox/hq ADR 0163, rule 6).
|
|
||||||
func TestAnUnplacedAccessIsRefusedByName(t *testing.T) {
|
func TestAnUnplacedAccessIsRefusedByName(t *testing.T) {
|
||||||
got, err := Resolve(shelf(placeable()), []string{"arr"}, workstation(), World{})
|
got, err := Resolve(shelf(placeable()), []string{"arr"}, workstation(), World{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
with := placedBy(map[string]any{
|
_, err = got.Declaration(placedBy(map[string]any{
|
||||||
AccessesSetting: map[string]any{"series": "/storage/media/series"},
|
AccessesSetting: map[string]any{"series": "/storage/media/series"},
|
||||||
})
|
}))
|
||||||
composed, err := got.Compose(with)
|
if err == nil || !strings.Contains(err.Error(), `"spool"`) || !strings.Contains(err.Error(), AccessesSetting) {
|
||||||
if err != nil {
|
t.Fatalf("an access nobody placed was not refused by name: %v", err)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
why := composed.LeftOut["arr"]
|
|
||||||
if why == "" || !strings.Contains(why, `"spool"`) || !strings.Contains(why, AccessesSetting) {
|
|
||||||
t.Fatalf("an access nobody placed was not refused by name: %v", composed.LeftOut)
|
|
||||||
}
|
|
||||||
if _, declared := byID(composed.Resources)["arr.server"]; declared {
|
|
||||||
t.Fatal("the module with the unplaced access was declared anyway")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"reflect"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// What a provider receives is composed once, and issued twice: as its received file, and in its
|
|
||||||
// membership on the bus (novox/hq ADR 0167). The two are the same list, so a proxy reading the bus
|
|
||||||
// and one reading the file serve the same routes — including the port the machine published, which
|
|
||||||
// is the same-node fix the file already carries.
|
|
||||||
func TestWhatAProviderReceivesIsTheSameOnTheBusAsInItsFile(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)
|
|
||||||
}
|
|
||||||
composed, err := r.Compose(Rendering{Ports: map[string]map[int]int{"gitea": {3000: 20000}}})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
file := fileNamed(composed.Resources, "route-proxy.received-route")
|
|
||||||
if file == nil {
|
|
||||||
t.Fatal("the proxy was given no routes file")
|
|
||||||
}
|
|
||||||
var written struct {
|
|
||||||
Given []Contribution `json:"given"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal([]byte(file["content"].(string)), &written); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
issued, said := composed.Received["route-proxy"]["route"]
|
|
||||||
if !said {
|
|
||||||
t.Fatalf("nothing is issued for the proxy to receive on the bus: %v", composed.Received)
|
|
||||||
}
|
|
||||||
// Compared as JSON, which is what both are once they leave the controller.
|
|
||||||
a, _ := json.Marshal(written.Given)
|
|
||||||
b, _ := json.Marshal(issued)
|
|
||||||
var fromFile, fromBus any
|
|
||||||
_ = json.Unmarshal(a, &fromFile)
|
|
||||||
_ = json.Unmarshal(b, &fromBus)
|
|
||||||
if !reflect.DeepEqual(fromFile, fromBus) {
|
|
||||||
t.Errorf("the bus and the file disagree about the routes:\nfile %s\nbus %s", a, b)
|
|
||||||
}
|
|
||||||
if len(issued) != 1 {
|
|
||||||
t.Fatalf("expected one route on the bus, got %v", issued)
|
|
||||||
}
|
|
||||||
if port, ok := asPort(issued[0].Values["port"]); !ok || port != 20000 {
|
|
||||||
t.Errorf("the bus carries a port nothing listens on: %v", issued[0].Values["port"])
|
|
||||||
}
|
|
||||||
|
|
||||||
// A module that receives nothing is issued nothing to receive.
|
|
||||||
if _, any := composed.Received["gitea"]; any {
|
|
||||||
t.Errorf("a module that receives nothing was issued something: %v", composed.Received["gitea"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -48,12 +48,11 @@ type World struct {
|
|||||||
Holdings []Held
|
Holdings []Held
|
||||||
// Offered is what other nodes provide at mesh scope, and everything needed to use it.
|
// Offered is what other nodes provide at mesh scope, and everything needed to use it.
|
||||||
Offered map[string][]Provider
|
Offered map[string][]Provider
|
||||||
// Pinned is which provider this machine was told to get a provision from, by name: a module and
|
// Pinned is which node this machine was told to get a provision from, by name. Only consulted
|
||||||
// the node it runs on, both (novox/hq #258). Only consulted when more than one could answer -- a
|
// when more than one node could answer -- a choice recorded before it was needed should not
|
||||||
// choice recorded before it was needed should not start meaning something the day a second
|
// start meaning something the day a second provider appears, and one recorded and then made
|
||||||
// provider appears, and one recorded and then made unnecessary should not quietly stop applying
|
// unnecessary should not quietly stop applying either.
|
||||||
// either.
|
Pinned map[string]string
|
||||||
Pinned map[string]Chosen
|
|
||||||
// Licences is every provision answered by a **record rather than a node**, by provision name.
|
// Licences is every provision answered by a **record rather than a node**, by provision name.
|
||||||
//
|
//
|
||||||
// novox/hq ADR 0024: a hosted model is on nobody's machine and is reached over the public
|
// novox/hq ADR 0024: a hosted model is on nobody's machine and is reached over the public
|
||||||
@@ -261,10 +260,6 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
|||||||
// still "choose one" after somebody has chosen one. That makes the remedy useless, and it is
|
// still "choose one" after somebody has chosen one. That makes the remedy useless, and it is
|
||||||
// how this read when first used.
|
// how this read when first used.
|
||||||
satisfied := map[string]bool{}
|
satisfied := map[string]bool{}
|
||||||
// Which modules a person assigned here, hostable. The walk marks a module chosen only when it
|
|
||||||
// reaches it, and a consumer may be reached before the provider beside it — so the provider of
|
|
||||||
// something already satisfied is looked for among these as well as among the chosen.
|
|
||||||
assignedHere := map[string]bool{}
|
|
||||||
|
|
||||||
// Everything a person assigned goes in first, except what this machine cannot run. Those are
|
// Everything a person assigned goes in first, except what this machine cannot run. Those are
|
||||||
// choices already made, and a requirement one of them answers is not a choice to put back to
|
// choices already made, and a requirement one of them answers is not a choice to put back to
|
||||||
@@ -289,7 +284,6 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
|||||||
for _, o := range m.Offers() {
|
for _, o := range m.Offers() {
|
||||||
satisfied[o] = true
|
satisfied[o] = true
|
||||||
}
|
}
|
||||||
assignedHere[a] = true
|
|
||||||
}
|
}
|
||||||
because[a] = "assigned"
|
because[a] = "assigned"
|
||||||
queue = append(queue, a)
|
queue = append(queue, a)
|
||||||
@@ -317,51 +311,6 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
|||||||
// commonest arrangement of all — a service and its database on one node — the weakest
|
// commonest arrangement of all — a service and its database on one node — the weakest
|
||||||
// handling, silently.
|
// handling, silently.
|
||||||
if satisfied[want] && !isModule(catalogue, want) {
|
if satisfied[want] && !isModule(catalogue, want) {
|
||||||
here := func(name string) bool { return chosen[name] || assignedHere[name] }
|
|
||||||
local := providersHere(catalogue, here, want)
|
|
||||||
// Which of them it matters to choose between. A plain capability — a shell, a display
|
|
||||||
// server — asks nothing of whoever answers it, and three shells beside an editor are
|
|
||||||
// not a choice to put to anybody. One that grants a credential, or serves a fact the
|
|
||||||
// consumer cannot guess, becomes a binding, and a binding is to one provider.
|
|
||||||
matter := local
|
|
||||||
if !brokered[want] {
|
|
||||||
matter = nil
|
|
||||||
for _, name := range local {
|
|
||||||
if _, ok := catalogue[name].Serves[want]; ok {
|
|
||||||
matter = append(matter, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var by Manifest
|
|
||||||
switch len(matter) {
|
|
||||||
case 0:
|
|
||||||
// Nothing to bind to; satisfied by its presence, as it was.
|
|
||||||
case 1:
|
|
||||||
by = catalogue[matter[0]]
|
|
||||||
default:
|
|
||||||
// Two modules on this machine answer it. Taking whichever a map walk met first
|
|
||||||
// was the rule until novox/hq #258 — random, per plan — and the same stance as
|
|
||||||
// across machines applies: ambiguity is refused, never resolved by picking.
|
|
||||||
//
|
|
||||||
// **Not in the first pass.** That pass exists only to answer *what does this node
|
|
||||||
// offer*, and refusing there makes the machine vanish rather than report a problem
|
|
||||||
// (the sibling case below says why): every other node then loses what this one
|
|
||||||
// provides — the vault, the identity provider — and refuses for a fault that is
|
|
||||||
// this node's to settle. The second pass refuses it properly, where it is asked.
|
|
||||||
if world.Unchecked {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
c, pinned := world.Pinned[want]
|
|
||||||
if !pinned || c.Node != node.Name || !oneOf(matter, c.Module) {
|
|
||||||
reported[want] = true
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s provides %q %d times, wanted by %s — say which with `pin %s %s %s <module>`: %s",
|
|
||||||
node.Name, want, len(matter), because[want], node.Name, want, node.Name,
|
|
||||||
strings.Join(matter, ", ")))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
by = catalogue[c.Module]
|
|
||||||
}
|
|
||||||
if brokered[want] {
|
if brokered[want] {
|
||||||
// Answered here, and still a need: the provider is this node.
|
// Answered here, and still a need: the provider is this node.
|
||||||
//
|
//
|
||||||
@@ -377,9 +326,9 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
|||||||
}
|
}
|
||||||
needs = append(needs, Needed{
|
needs = append(needs, Needed{
|
||||||
Name: want, From: node.Name, At: at,
|
Name: want, From: node.Name, At: at,
|
||||||
Serves: servedByOne(by, want), For: because[want],
|
Serves: servedHere(catalogue, chosen, want), For: because[want],
|
||||||
SharedOwn: sharedByOne(by, want)})
|
SharedOwn: sharedHere(catalogue, chosen, want)})
|
||||||
} else if served := servedByOne(by, want); len(served) > 0 {
|
} else if served := servedHere(catalogue, chosen, want); len(served) > 0 {
|
||||||
// Answered here with no credential to mint, but the provider serves facts the
|
// Answered here with no credential to mint, but the provider serves facts the
|
||||||
// consumer cannot guess — a port, a model name — and so still needs a binding.
|
// consumer cannot guess — a port, a model name — and so still needs a binding.
|
||||||
// **The reachability rule does not apply**: both ends are on this same machine, so
|
// **The reachability rule does not apply**: both ends are on this same machine, so
|
||||||
@@ -409,7 +358,11 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
|||||||
if brokered[want] {
|
if brokered[want] {
|
||||||
reported[want] = true
|
reported[want] = true
|
||||||
where := world.Offered[want]
|
where := world.Offered[want]
|
||||||
names := providerNames(where)
|
names := make([]string, 0, len(where))
|
||||||
|
for _, p := range where {
|
||||||
|
names = append(names, p.Node)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
take := func(p Provider) {
|
take := func(p Provider) {
|
||||||
if node.At != "" && p.At == "" || node.At == "" && p.At != "" || node.At == "" && p.At == "" {
|
if node.At != "" && p.At == "" || node.At == "" && p.At != "" || node.At == "" && p.At == "" {
|
||||||
// One of them is not on the private network, so there is no path between
|
// One of them is not on the private network, so there is no path between
|
||||||
@@ -443,17 +396,17 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
|||||||
"nothing in this mesh provides %q, wanted by %s %s",
|
"nothing in this mesh provides %q, wanted by %s %s",
|
||||||
want, because[want], remedy))
|
want, because[want], remedy))
|
||||||
case len(where) == 1:
|
case len(where) == 1:
|
||||||
if c, pinned := world.Pinned[want]; pinned && !c.matches(where[0]) {
|
if chosenNode, pinned := world.Pinned[want]; pinned && chosenNode != where[0].Node {
|
||||||
// One provider, and it is not the one this machine was told to use. Silently
|
// One provider, and it is not the one this machine was told to use. Silently
|
||||||
// using the other would be the mesh overruling a choice somebody made.
|
// using the other would be the mesh overruling a choice somebody made.
|
||||||
problems = append(problems, fmt.Sprintf(
|
problems = append(problems, fmt.Sprintf(
|
||||||
"%s was told to get %q from %s, and only %s provides it",
|
"%s was told to get %q from %s, and only %s provides it",
|
||||||
node.Name, want, c, nameOf(where[0])))
|
node.Name, want, chosenNode, where[0].Node))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
take(where[0])
|
take(where[0])
|
||||||
default:
|
default:
|
||||||
c, pinned := world.Pinned[want]
|
chosenNode, pinned := world.Pinned[want]
|
||||||
if !pinned {
|
if !pinned {
|
||||||
// **The seat's holder answers, when a seat delivers this** (novox/hq ADR 0110).
|
// **The seat's holder answers, when a seat delivers this** (novox/hq ADR 0110).
|
||||||
// Not a guess, which ADR 0009 refuses: the choice was made once, mesh-wide, by
|
// Not a guess, which ADR 0009 refuses: the choice was made once, mesh-wide, by
|
||||||
@@ -465,32 +418,27 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
problems = append(problems, fmt.Sprintf(
|
problems = append(problems, fmt.Sprintf(
|
||||||
"%d providers of %q, wanted by %s — say which with `pin %s %s <node> <module>`: %s",
|
"%d nodes provide %q, wanted by %s — say which with `pin %s %s <node>`: %s",
|
||||||
len(where), want, because[want], node.Name, want,
|
len(where), want, because[want], node.Name, want,
|
||||||
strings.Join(names, ", ")))
|
strings.Join(names, ", ")))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
matching := c.among(where)
|
var chosen *Provider
|
||||||
switch len(matching) {
|
for i, w := range where {
|
||||||
case 0:
|
if w.Node == chosenNode {
|
||||||
// Pointed at a provider that does not answer this. Refused rather than
|
chosen = &where[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if chosen == nil {
|
||||||
|
// Pointed at a machine that does not answer this. Refused rather than
|
||||||
// falling back to another: a fallback would quietly move somebody's data to
|
// falling back to another: a fallback would quietly move somebody's data to
|
||||||
// a machine they did not choose, which is the whole reason this is asked.
|
// a machine they did not choose, which is the whole reason this is asked.
|
||||||
problems = append(problems, fmt.Sprintf(
|
problems = append(problems, fmt.Sprintf(
|
||||||
"%s was told to get %q from %s, and %s does not provide it — these do: %s",
|
"%s was told to get %q from %s, and %s does not provide it — these do: %s",
|
||||||
node.Name, want, c, c, strings.Join(names, ", ")))
|
node.Name, want, chosenNode, chosenNode, strings.Join(names, ", ")))
|
||||||
case 1:
|
break
|
||||||
take(matching[0])
|
|
||||||
default:
|
|
||||||
// A record naming only the node, from before a pin named the module, and that
|
|
||||||
// node answers twice. This once took the last one listed (novox/hq #258): a
|
|
||||||
// coin flip, handed to whoever reads the certificate it chose.
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s was told to get %q from %s, and %s provides it %d times — say which with "+
|
|
||||||
"`pin %s %s %s <module>`: %s",
|
|
||||||
node.Name, want, c, c.Node, len(matching), node.Name, want, c.Node,
|
|
||||||
strings.Join(providerNames(matching), ", ")))
|
|
||||||
}
|
}
|
||||||
|
take(*chosen)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -649,6 +597,31 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
|||||||
// need that is never created is a binding the consumer never gets. It is right about that from the
|
// need that is never created is a binding the consumer never gets. It is right about that from the
|
||||||
// manifest alone, which is why walking the catalogue mid-resolution is enough here and is not
|
// manifest alone, which is why walking the catalogue mid-resolution is enough here and is not
|
||||||
// enough for the values.
|
// enough for the values.
|
||||||
|
// sharedHere is the own secret the provider of a provision on this same machine names as its
|
||||||
|
// credential (ADR 0158), or "" when the provider gives each consumer its own.
|
||||||
|
func sharedHere(catalogue map[string]Manifest, chosen map[string]bool, want string) string {
|
||||||
|
for name, m := range catalogue {
|
||||||
|
if !chosen[name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if own, shared := m.SharedCredentialOf(want); shared {
|
||||||
|
return own
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func servedHere(catalogue map[string]Manifest, chosen map[string]bool, want string) map[string]any {
|
||||||
|
for name, m := range catalogue {
|
||||||
|
if !chosen[name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := m.Serves[want]; ok {
|
||||||
|
return ServedOn(m, want, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// isModule reports whether a name is a module in its own right rather than only something
|
// isModule reports whether a name is a module in its own right rather than only something
|
||||||
// modules provide.
|
// modules provide.
|
||||||
|
|||||||
@@ -48,9 +48,7 @@ func TestTheResolverForwardsToFixedUpstreamsAndNeverReadsResolvConf(t *testing.T
|
|||||||
}
|
}
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
"\nno-resolv\n", "\nserver=1.1.1.1\n", "\nserver=8.8.8.8\n",
|
"\nno-resolv\n", "\nserver=1.1.1.1\n", "\nserver=8.8.8.8\n",
|
||||||
// Loopback is the mesh-wide setting's default; a machine answering its own LAN adds its
|
"\nlisten-address=127.0.0.1\n", "\nlisten-address=${machine:address}\n", "\nbind-dynamic\n",
|
||||||
// address there (novox/hq issue 198).
|
|
||||||
"\nlisten-address=${setting:listen-addresses}\n", "\nlisten-address=${machine:address}\n", "\nbind-dynamic\n",
|
|
||||||
"\ndomain-needed\n", "\nbogus-priv\n",
|
"\ndomain-needed\n", "\nbogus-priv\n",
|
||||||
"\nconf-file=" + m.Facts["node-zones"].Path + "\n",
|
"\nconf-file=" + m.Facts["node-zones"].Path + "\n",
|
||||||
} {
|
} {
|
||||||
@@ -116,7 +114,6 @@ func TestTheResolverAndWhatAsksItComposeOnOneMachine(t *testing.T) {
|
|||||||
// happen to be the same map, since nothing routed is part of it.
|
// happen to be the same map, since nothing routed is part of it.
|
||||||
Names: twoMachines, Machines: twoMachines, Suffix: "internal",
|
Names: twoMachines, Machines: twoMachines, Suffix: "internal",
|
||||||
Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}},
|
Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}},
|
||||||
Settings: SettingsBy{"dnsmasq": {{From: "the mesh", Values: map[string]any{"listen-addresses": "127.0.0.1"}}}},
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -210,16 +207,9 @@ func TestTheResolverOnAMachineOffTheNetworkIsRefused(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// Left out of the declaration and said, rather than composed listening nowhere: a module that
|
_, err = got.Declaration(Rendering{Names: twoMachines, Suffix: "internal",
|
||||||
// cannot compose on a machine is kept as it is there, with the reason (hq ADR 0163).
|
Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}}})
|
||||||
composed, err := got.Compose(Rendering{Names: twoMachines, Suffix: "internal",
|
if err == nil || !strings.Contains(err.Error(), "${machine:address}") {
|
||||||
Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}},
|
t.Fatalf("a machine off the network was composed a resolver, or refused for another reason: %v", err)
|
||||||
Settings: SettingsBy{"dnsmasq": {{From: "the mesh", Values: map[string]any{"listen-addresses": "127.0.0.1"}}}}})
|
|
||||||
if err == nil && !strings.Contains(composed.LeftOut["dnsmasq"], "${machine:address}") {
|
|
||||||
t.Fatalf("a machine off the network was composed a resolver, or left out for another reason: %v",
|
|
||||||
composed.LeftOut)
|
|
||||||
}
|
|
||||||
if err != nil && !strings.Contains(err.Error(), "${machine:address}") {
|
|
||||||
t.Fatalf("a machine off the network was refused for another reason: %v", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,26 +59,13 @@ var defaultSeats = []Seat{
|
|||||||
{Name: ControllerSeatName, Scope: ScopeMesh, Decision: "novox/hq ADR 0079",
|
{Name: ControllerSeatName, Scope: ScopeMesh, Decision: "novox/hq ADR 0079",
|
||||||
Emits: []string{"applied", "refused", "built-before"},
|
Emits: []string{"applied", "refused", "built-before"},
|
||||||
Serves: ControllerVerbs},
|
Serves: ControllerVerbs},
|
||||||
// The store's first verbs (novox/hq ADR 0159): the smallest set that makes the store askable,
|
{Name: "mesh-store", Scope: ScopeMesh, Delivers: "postgres-database", Decision: "novox/hq ADR 0079"},
|
||||||
// served by whichever module holds the seat with tools of these names.
|
|
||||||
{Name: "mesh-store", Scope: ScopeMesh, Delivers: "postgres-database", Decision: "novox/hq ADR 0079",
|
|
||||||
Serves: []Verb{
|
|
||||||
{Name: "databases", Description: "Every database the store holds, with its on-disk size.",
|
|
||||||
Input: schema(map[string]string{}, nil)},
|
|
||||||
{Name: "query", Description: "One read-only statement against one database the store holds.",
|
|
||||||
Input: schema(map[string]string{"database": "the database to query", "sql": "the read-only statement"},
|
|
||||||
[]string{"database", "sql"})},
|
|
||||||
}},
|
|
||||||
// **Delivers the mesh's own bus, not `amqp`.** Those were the same word until
|
// **Delivers the mesh's own bus, not `amqp`.** Those were the same word until
|
||||||
// ADR 0127 separated them: `amqp` is a backing service a module may require, and this seat is
|
// ADR 0127 separated them: `amqp` is a backing service a module may require, and this seat is
|
||||||
// the mesh's own transport. ADR 0128 then made that connection something a module requires
|
// the mesh's own transport. ADR 0128 then made that connection something a module requires
|
||||||
// rather than receives ambiently — 23 of the catalogue's modules never speak, and an ambient
|
// rather than receives ambiently — 23 of the catalogue's modules never speak, and an ambient
|
||||||
// connection would mint a credential for each.
|
// connection would mint a credential for each.
|
||||||
{Name: "mesh-broker", Scope: ScopeMesh, Delivers: "mesh-bus", Decision: "novox/hq ADR 0079"},
|
{Name: "mesh-broker", Scope: ScopeMesh, Delivers: "mesh-bus", Decision: "novox/hq ADR 0079"},
|
||||||
// The vault: the controller seals every minted credential with what it provides, which is the
|
|
||||||
// test for a seat of the mesh's own (novox/hq ADR 0161) — a second provider of `secret` is a
|
|
||||||
// second claimant, refused by name, rather than a candidate for a pin.
|
|
||||||
{Name: "mesh-vault", Scope: ScopeMesh, Delivers: "secret", Decision: "novox/hq ADR 0161"},
|
|
||||||
// Named for its scope since 2026-09-30 (novox/hq ADR 0156); `the-artifact-store` resolves to it as
|
// Named for its scope since 2026-09-30 (novox/hq ADR 0156); `the-artifact-store` resolves to it as
|
||||||
// an alias on a mesh that predates the rename. It serves artifacts of every kind a build makes —
|
// an alias on a mesh that predates the rename. It serves artifacts of every kind a build makes —
|
||||||
// images and archives, by digest — which is why the provision is the artifact store and not an
|
// images and archives, by digest — which is why the provision is the artifact store and not an
|
||||||
@@ -99,48 +86,8 @@ var defaultSeats = []Seat{
|
|||||||
{Name: "mesh-build-machine", Scope: ScopeMesh,
|
{Name: "mesh-build-machine", Scope: ScopeMesh,
|
||||||
Accepts: []string{"build"}, Emits: []string{"started", "built", "log.*"}, Decision: "novox/hq ADR 0121"},
|
Accepts: []string{"build"}, Emits: []string{"started", "built", "log.*"}, Decision: "novox/hq ADR 0121"},
|
||||||
{Name: "node-dns-resolver", Scope: ScopeNode, Decision: "novox/hq ADR 0121"},
|
{Name: "node-dns-resolver", Scope: ScopeNode, Decision: "novox/hq ADR 0121"},
|
||||||
// The intrusion prevention's verbs (novox/hq ADR 0179): what a person asks a machine's ban list
|
{Name: "node-intrusion-prevention", Scope: ScopeNode, Decision: "novox/hq ADR 0121"},
|
||||||
// whatever keeps it — who is banned and why, ban one address, let one go. Every holder serves all
|
{Name: "node-packet-filter", Scope: ScopeNode, Decision: "novox/hq ADR 0121"},
|
||||||
// four; the jails themselves are composed from the modules the machine runs (to-be 31).
|
|
||||||
{Name: "node-intrusion-prevention", Scope: ScopeNode, Decision: "novox/hq ADR 0121",
|
|
||||||
Serves: []Verb{
|
|
||||||
{Name: "status", Description: "Every jail on this machine with how many it is watching and " +
|
|
||||||
"holding now, and the totals since the jail started; one jail's detail when named.",
|
|
||||||
Input: schema(map[string]string{"jail": "one jail (optional)"}, nil)},
|
|
||||||
{Name: "banned", Description: "Every address banned on this machine right now, with the jail " +
|
|
||||||
"that holds it and when the ban ends.",
|
|
||||||
Input: schema(map[string]string{"jail": "one jail (optional)"}, nil)},
|
|
||||||
{Name: "ban", Description: "Ban one address in one jail now, for the jail's ban time — an " +
|
|
||||||
"operator's act on the live ban list, which the mesh never writes itself.",
|
|
||||||
Input: schema(map[string]string{"ip": "the address", "jail": "the jail to hold it"}, []string{"ip", "jail"})},
|
|
||||||
{Name: "unban", Description: "Let one address go, from one jail or from every jail when none is named.",
|
|
||||||
Input: schema(map[string]string{"ip": "the address", "jail": "one jail (optional)"}, []string{"ip"})},
|
|
||||||
}},
|
|
||||||
// The packet filter's verbs (novox/hq ADR 0170): what a person asks a machine's filter whatever
|
|
||||||
// filter answers — the rules as enforced, reload the mesh's own, remove one thing the mesh did
|
|
||||||
// not write. Every holder serves all three; what differs by filter is the holder's own tools.
|
|
||||||
{Name: "node-packet-filter", Scope: ScopeNode, Decision: "novox/hq ADR 0121",
|
|
||||||
Serves: []Verb{
|
|
||||||
{Name: "rules", Description: "The packet filter as this machine enforces it now: the nftables " +
|
|
||||||
"ruleset and, where the tool exists, the legacy filter's listings. Narrowed to one table or " +
|
|
||||||
"chain when asked.",
|
|
||||||
Input: schema(map[string]string{"table": "one nftables table, as `family name` (optional)",
|
|
||||||
"chain": "one chain of that table (optional)"}, nil)},
|
|
||||||
{Name: "reload", Description: "Load the mesh's own filter again from the file the mesh writes, " +
|
|
||||||
"and answer with the mesh's table as loaded.",
|
|
||||||
Input: schema(map[string]string{}, nil)},
|
|
||||||
{Name: "remove", Description: "Remove one rule set the mesh did not write, named exactly as the " +
|
|
||||||
"host reports it (novox/hq ADR 0168) — `chain X (iptables-legacy)` or `table ip6 filter, chain " +
|
|
||||||
"DOCKER-USER`. Refuses the mesh's tables, the runtime's own chains, a built-in chain and an " +
|
|
||||||
"active found firewall's chains. An operator's act, by name, never a flush.",
|
|
||||||
Input: schema(map[string]string{"where": "the rule set, as `node show` lists it"}, []string{"where"})},
|
|
||||||
}},
|
|
||||||
// The machine's service manager (novox/hq ADR 0177). The host applies every declared unit,
|
|
||||||
// system or user scope; the holder answers questions and operator acts about them, each verb
|
|
||||||
// taking the unit and an optional scope. The holder runs nothing of its own: its verbs are
|
|
||||||
// served by the node tools runtime (ADR 0175).
|
|
||||||
{Name: "node-service-manager", Scope: ScopeNode, Decision: "novox/hq ADR 0177",
|
|
||||||
Serves: serviceManagerVerbs()},
|
|
||||||
// Deferred (novox/hq ADR 0121): renaming to mesh-private-network is a scope + server/client
|
// Deferred (novox/hq ADR 0121): renaming to mesh-private-network is a scope + server/client
|
||||||
// model change, not a rename, so it stays until that is built.
|
// model change, not a rename, so it stays until that is built.
|
||||||
{Name: "the-private-network", Scope: ScopeNode, Decision: "novox/hq ADR 0110"},
|
{Name: "the-private-network", Scope: ScopeNode, Decision: "novox/hq ADR 0110"},
|
||||||
@@ -339,19 +286,11 @@ func CanHold(m Manifest, seat Seat) error {
|
|||||||
// **Serving the seat's tools is a condition of holding it** (novox/hq ADR 0132). A holder that
|
// **Serving the seat's tools is a condition of holding it** (novox/hq ADR 0132). A holder that
|
||||||
// does not answer what the role promises is every caller's timeout, found at registration and
|
// does not answer what the role promises is every caller's timeout, found at registration and
|
||||||
// at handover instead, naming the verbs rather than the fact that something is missing.
|
// at handover instead, naming the verbs rather than the fact that something is missing.
|
||||||
if missing := unservedVerbs(claimed.ServesFor(m), seat.Serves); len(missing) > 0 {
|
if missing := unservedVerbs(m.Tools, seat.Serves); len(missing) > 0 {
|
||||||
return fmt.Errorf("%s claims %s but does not serve %s, which that seat's protocol promises "+
|
return fmt.Errorf("%s claims %s but does not serve %s, which that seat's protocol promises "+
|
||||||
"(novox/hq ADR 0132) — a holder names every verb its seat declares, under the claim's "+
|
"(novox/hq ADR 0132) — a holder lists every verb its seat declares under tools",
|
||||||
"serves or among its own tools",
|
|
||||||
m.Module, seat.Name, strings.Join(missing, ", "))
|
m.Module, seat.Name, strings.Join(missing, ", "))
|
||||||
}
|
}
|
||||||
// And nothing the seat does not promise: a verb named here that the protocol lacks is served
|
|
||||||
// to nobody, which is a typo the holder would otherwise discover as a caller's timeout.
|
|
||||||
if extra := unpromised(claimed.Serves, seat.Serves); len(extra) > 0 {
|
|
||||||
return fmt.Errorf("%s claims %s and says it serves %s, which that seat's protocol does not "+
|
|
||||||
"promise — a claim's serves names the seat's verbs and nothing else",
|
|
||||||
m.Module, seat.Name, strings.Join(extra, ", "))
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,36 +353,3 @@ func SeatsWithAProtocol() []Seat {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// serviceManagerVerbs is the contract every holder of node-service-manager serves (novox/hq ADR
|
|
||||||
// 0177): the units on the machine in both scopes, read and acted on by name. Every verb takes an
|
|
||||||
// optional scope — "system" when absent, "user" for the operator account's own manager — so a
|
|
||||||
// caller asks for a user unit the way it asks for a system one.
|
|
||||||
func serviceManagerVerbs() []Verb {
|
|
||||||
scoped := func(more map[string]string, required []string) map[string]any {
|
|
||||||
props := map[string]string{"scope": "\"system\" (the default) or \"user\": the operator account's own manager"}
|
|
||||||
for k, v := range more {
|
|
||||||
props[k] = v
|
|
||||||
}
|
|
||||||
return schema(props, required)
|
|
||||||
}
|
|
||||||
unit := map[string]string{"unit": "the unit's name, as the service manager knows it"}
|
|
||||||
return []Verb{
|
|
||||||
{Name: "units", Description: "The units the service manager knows in a scope, each with its load, active and sub state; narrowed to a pattern when asked.",
|
|
||||||
Input: scoped(map[string]string{"pattern": "a glob the unit's name must match (optional)"}, nil)},
|
|
||||||
{Name: "status", Description: "One unit as the service manager sees it now: its states, whether it starts at boot, its main process, and whether the mesh declares it.",
|
|
||||||
Input: scoped(unit, []string{"unit"})},
|
|
||||||
{Name: "start", Description: "Start one unit. For a unit the mesh declares, the answer says the host will restore what its declaration says at the next apply.",
|
|
||||||
Input: scoped(unit, []string{"unit"})},
|
|
||||||
{Name: "stop", Description: "Stop one unit; for a mesh-declared unit the answer says the host will restore its declared state.",
|
|
||||||
Input: scoped(unit, []string{"unit"})},
|
|
||||||
{Name: "restart", Description: "Restart one unit.",
|
|
||||||
Input: scoped(unit, []string{"unit"})},
|
|
||||||
{Name: "enable", Description: "Make one unit start at boot (or at the account's login, in user scope).",
|
|
||||||
Input: scoped(unit, []string{"unit"})},
|
|
||||||
{Name: "disable", Description: "Stop one unit starting at boot (or at login, in user scope).",
|
|
||||||
Input: scoped(unit, []string{"unit"})},
|
|
||||||
{Name: "journal", Description: "The last lines of one unit's journal.",
|
|
||||||
Input: scoped(map[string]string{"unit": unit["unit"], "lines": "how many lines from the end (default 100)"}, []string{"unit"})},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ func CatalogueProblems(shelf Shelf) []string {
|
|||||||
}
|
}
|
||||||
// A holder that does not answer what the seat promises is a caller's timeout, found
|
// A holder that does not answer what the seat promises is a caller's timeout, found
|
||||||
// at assignment instead.
|
// at assignment instead.
|
||||||
if missing := unserved(m, c, s); len(missing) > 0 {
|
if missing := unserved(m, s); len(missing) > 0 {
|
||||||
problems = append(problems, fmt.Sprintf(
|
problems = append(problems, fmt.Sprintf(
|
||||||
"%s claims %s but does not serve %s, which that seat's protocol promises",
|
"%s claims %s but does not serve %s, which that seat's protocol promises",
|
||||||
module, c.Name, strings.Join(missing, ", ")))
|
module, c.Name, strings.Join(missing, ", ")))
|
||||||
@@ -221,10 +221,10 @@ func CatalogueProblems(shelf Shelf) []string {
|
|||||||
|
|
||||||
// unserved is what a seat's protocol promises and the claimant does not answer. Only the tools
|
// unserved is what a seat's protocol promises and the claimant does not answer. Only the tools
|
||||||
// are checked: `accepts` and `emits` are wired by the runtime from the declaration, while a tool
|
// are checked: `accepts` and `emits` are wired by the runtime from the declaration, while a tool
|
||||||
// is code the module either has or has not written — under the claim's serves, or among its own.
|
// is code the module either has or has not written.
|
||||||
func unserved(m Manifest, c Claim, s SeatDeclaration) []string {
|
func unserved(m Manifest, s SeatDeclaration) []string {
|
||||||
has := map[string]bool{}
|
has := map[string]bool{}
|
||||||
for _, t := range c.ServesFor(m) {
|
for _, t := range m.Tools {
|
||||||
has[t] = true
|
has[t] = true
|
||||||
}
|
}
|
||||||
var missing []string
|
var missing []string
|
||||||
|
|||||||
@@ -70,22 +70,6 @@ func TestAHolderMustServeWhatItsSeatPromises(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The claim may say what it serves for the role instead, when the module's own tools are not the
|
|
||||||
// seat's verbs (ADR 0160).
|
|
||||||
func TestAClaimMayNameWhatItServesForTheSeat(t *testing.T) {
|
|
||||||
m := telegram()
|
|
||||||
m.Tools = []string{"telegram_send"}
|
|
||||||
m.Claims = append([]Claim(nil), m.Claims...)
|
|
||||||
for i := range m.Claims {
|
|
||||||
if m.Claims[i].Name == "telegram-sender" {
|
|
||||||
m.Claims[i].Serves = []string{"status"}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if got := problemsFor(t, Shelf{"telegram": m}); strings.Contains(got, "does not serve") {
|
|
||||||
t.Fatalf("a claim naming the seat's verb was refused: %s", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A seat with no protocol is a marker: which module is this node's showcase, or its packet filter.
|
// A seat with no protocol is a marker: which module is this node's showcase, or its packet filter.
|
||||||
// Most node-scoped seats are markers, so refusing one would refuse the majority of the set.
|
// Most node-scoped seats are markers, so refusing one would refuse the majority of the set.
|
||||||
func TestASeatWithoutAProtocolIsAMarkerNotAMistake(t *testing.T) {
|
func TestASeatWithoutAProtocolIsAMarkerNotAMistake(t *testing.T) {
|
||||||
|
|||||||
@@ -44,9 +44,8 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
|
|||||||
delivered[s.Delivers] = s.Name
|
delivered[s.Delivers] = s.Name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Sixteen since node-service-manager (novox/hq ADR 0177).
|
if len(Seats()) != 14 {
|
||||||
if len(Seats()) != 16 {
|
t.Errorf("the mesh defines %d seats rather than 14; the set is closed, so a change here is "+
|
||||||
t.Errorf("the mesh defines %d seats rather than 16; the set is closed, so a change here is "+
|
|
||||||
"a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames())
|
"a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -250,7 +249,7 @@ func TestAPinStillWinsOverTheSeat(t *testing.T) {
|
|||||||
// A consumer coupled to one provider's contents has said so, and the seat does not overrule it.
|
// A consumer coupled to one provider's contents has said so, and the seat does not overrule it.
|
||||||
got, err := Resolve(registryShelf(), []string{"builder"}, reachable(),
|
got, err := Resolve(registryShelf(), []string{"builder"}, reachable(),
|
||||||
World{Offered: twoRegistries(), Held: giteaHoldsTheSeat(),
|
World{Offered: twoRegistries(), Held: giteaHoldsTheSeat(),
|
||||||
Pinned: map[string]Chosen{"npm-package-registry": {Node: "archive", Module: "verdaccio"}}})
|
Pinned: map[string]string{"npm-package-registry": "archive"}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package catalogue
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -276,11 +275,6 @@ func UnusedSettings(m Manifest, layers []Layer) []string {
|
|||||||
if key == AccessesSetting && len(m.Accesses) > 0 {
|
if key == AccessesSetting && len(m.Accesses) > 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// `networks` keeps a found network for a taken container on one adopted machine
|
|
||||||
// (novox/hq ADR 0163). Validated in KeptNetworks, so not stray.
|
|
||||||
if key == NetworksSetting {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
unused = append(unused, fmt.Sprintf(
|
unused = append(unused, fmt.Sprintf(
|
||||||
"%s sets %q, and %s has no file that merges it, asks for no ${setting:%s}, and "+
|
"%s sets %q, and %s has no file that merges it, asks for no ${setting:%s}, and "+
|
||||||
"declares no %q in what it contributes or serves",
|
"declares no %q in what it contributes or serves",
|
||||||
@@ -304,125 +298,3 @@ func stringsOf(v any) []string {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// NetworksSetting is the settings key that keeps a found network for a taken container, on one
|
|
||||||
// adopted machine (novox/hq ADR 0163, rule 4):
|
|
||||||
//
|
|
||||||
// {"networks": {"server": ["predecessor_default"]}}
|
|
||||||
//
|
|
||||||
// has the module's container `server` also join `predecessor_default` once taken, so a neighbour
|
|
||||||
// that resolves it by name on that network keeps resolving it. Migration scaffolding in the sense
|
|
||||||
// of ADR 0104: assigned only on an adopted machine, reported while it stands, removed when the
|
|
||||||
// neighbours are taken. Keyed by the container's resource id; the value is the networks it keeps.
|
|
||||||
const NetworksSetting = "networks"
|
|
||||||
|
|
||||||
// KeptNetworks reads which found networks each of a module's containers keeps, by container id.
|
|
||||||
//
|
|
||||||
// Refused from a mesh-wide layer — a found network is a fact about one machine — for an id the
|
|
||||||
// module declares no container under, for a name that is not a network's, and on a machine that
|
|
||||||
// is not adopted: the setting exists so neighbours the mesh has not taken yet keep reaching the
|
|
||||||
// container, and a converged machine has no such neighbours.
|
|
||||||
func KeptNetworks(m Manifest, layers []Layer, adopted bool) (map[string][]string, error) {
|
|
||||||
containers := map[string]bool{}
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
if fmt.Sprint(r["type"]) == "container" {
|
|
||||||
containers[fmt.Sprint(r["id"])] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out := map[string][]string{}
|
|
||||||
for _, layer := range layers {
|
|
||||||
raw, ok := layer.Values[NetworksSetting]
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if layer.From == MeshWideLayer {
|
|
||||||
return nil, fmt.Errorf("%s: %s is given per node — a found network is a fact about one "+
|
|
||||||
"machine; set it with --node", m.Module, NetworksSetting)
|
|
||||||
}
|
|
||||||
if !adopted {
|
|
||||||
return nil, fmt.Errorf("%s: %s keeps a found network for neighbours the mesh has not taken "+
|
|
||||||
"yet, and %s is converged — nothing on it is found; clear the setting", m.Module,
|
|
||||||
NetworksSetting, layer.From)
|
|
||||||
}
|
|
||||||
blocks, ok := raw.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("%s: %s is a { container: [network, …] } map, and %q set it to "+
|
|
||||||
"something else", m.Module, NetworksSetting, layer.From)
|
|
||||||
}
|
|
||||||
for id, body := range blocks {
|
|
||||||
if !containers[id] {
|
|
||||||
return nil, fmt.Errorf("%s: %s names the container %q, which it does not declare — "+
|
|
||||||
"the setting reaches nothing; it declares %s", m.Module, NetworksSetting, id,
|
|
||||||
orNothing(sortedKeys(containers)))
|
|
||||||
}
|
|
||||||
names := stringsOf(body)
|
|
||||||
if len(names) == 0 {
|
|
||||||
return nil, fmt.Errorf("%s: %s for %q is a list of network names, and %q set it to %v",
|
|
||||||
m.Module, NetworksSetting, id, layer.From, body)
|
|
||||||
}
|
|
||||||
for _, n := range names {
|
|
||||||
if !networkName.MatchString(n) {
|
|
||||||
return nil, fmt.Errorf("%s: %s for %q names %q, which is not a network name",
|
|
||||||
m.Module, NetworksSetting, id, n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(names)
|
|
||||||
out[id] = names
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(out) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// networkName is what a container runtime accepts as a network's name.
|
|
||||||
var networkName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*$`)
|
|
||||||
|
|
||||||
// JudgeSettings composes a module's settings against its definition and refuses the first thing
|
|
||||||
// that cannot work, naming the module, the layer and the key (novox/hq ADR 0163, rule 6).
|
|
||||||
//
|
|
||||||
// **The same judgement where a setting is stored and where a machine is declared.** Stored, a
|
|
||||||
// setting that cannot compose is refused before it is kept; composed later, a definition that has
|
|
||||||
// moved under a stored setting leaves that module out of the machine's declaration rather than
|
|
||||||
// the machine without one. Every reader of settings runs here: a port given, an exposure, a reach,
|
|
||||||
// an endpoint, a placement, an access, a kept network, a mergeable file's keys and a file's
|
|
||||||
// `${setting:…}`. A key that reaches nothing is not here: it cannot break a composition, so it is
|
|
||||||
// refused where it is stored (SetSettings, with UnusedSettings) and said where a plan is read,
|
|
||||||
// and never costs a module its place.
|
|
||||||
func JudgeSettings(m Manifest, layers []Layer, adopted bool) error {
|
|
||||||
// With no layers too: a definition may ask for a setting nobody made — an access placed by
|
|
||||||
// nobody, a file's ${setting:…} nothing sets — and that is the same statement, missing.
|
|
||||||
if _, err := GivenPorts(m, layers); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := Reaches(m, layers); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := Endpoints(m, layers); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := Places(m, layers); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, _, err := accessesFor(m, layers); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := KeptNetworks(m, layers, adopted); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, r := range m.Resources {
|
|
||||||
settled, err := ApplySettings(r, layers)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
copied := map[string]any{}
|
|
||||||
for k, v := range settled {
|
|
||||||
copied[k] = v
|
|
||||||
}
|
|
||||||
if err := settingInto(copied, layers, m.Module); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A setting is judged where it is stored, and an impossible one costs a module, not a machine
|
|
||||||
// (novox/hq ADR 0163, rule 6): the one judgement, used by SetSettings before storing and by
|
|
||||||
// Compose when a definition has moved under a stored setting.
|
|
||||||
func TestASettingThatCannotComposeIsRefusedByNameAndLeavesOnlyItsModuleOut(t *testing.T) {
|
|
||||||
web := Manifest{Module: "hello-web",
|
|
||||||
Listens: []Listening{{Port: 8080, From: FromEverywhere}},
|
|
||||||
Resources: []map[string]any{{"id": "server", "type": "container", "name": "hello-web",
|
|
||||||
"ports": []any{"8080"}}}}
|
|
||||||
for _, c := range []struct {
|
|
||||||
name string
|
|
||||||
layer map[string]any
|
|
||||||
refuse string
|
|
||||||
}{
|
|
||||||
{"a port the module does not publish", map[string]any{PortsSetting: map[string]any{"9999": 10000}},
|
|
||||||
"hello-web gives port 9999 a machine port, and no container of its publishes 9999"},
|
|
||||||
{"a mesh-wide port", map[string]any{PortsSetting: map[string]any{"8080": 10000}},
|
|
||||||
"a port is a fact about one machine"},
|
|
||||||
} {
|
|
||||||
from := "anchor"
|
|
||||||
if c.name == "a mesh-wide port" {
|
|
||||||
from = MeshWideLayer
|
|
||||||
}
|
|
||||||
err := JudgeSettings(web, []Layer{{From: from, Values: c.layer}}, true)
|
|
||||||
if err == nil || !strings.Contains(err.Error(), c.refuse) {
|
|
||||||
t.Errorf("%s: judged %v, want %q", c.name, err, c.refuse)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := JudgeSettings(web, []Layer{{From: "anchor", Values: map[string]any{PortsSetting: map[string]any{"8080": 10000}}}}, true); err != nil {
|
|
||||||
t.Fatalf("a port the module publishes was refused: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Composed, a module whose stored setting no longer works is left out by name; the rest of
|
|
||||||
// the machine is declared.
|
|
||||||
r := anAdoptedAnchor()
|
|
||||||
with := anchorRendering(false)
|
|
||||||
with.Settings = SettingsBy{"hello-web": {{From: "anchor", Values: map[string]any{PortsSetting: map[string]any{"9999": 10000}}}}}
|
|
||||||
composed, err := r.Compose(with)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
why, left := composed.LeftOut["hello-web"]
|
|
||||||
if !left || !strings.Contains(why, "no container of its publishes 9999") {
|
|
||||||
t.Fatalf("hello-web is not left out by name: %v", composed.LeftOut)
|
|
||||||
}
|
|
||||||
if len(composed.LeftOut) != 1 {
|
|
||||||
t.Fatalf("more than hello-web is left out: %v", composed.LeftOut)
|
|
||||||
}
|
|
||||||
got := byID(composed.Resources)
|
|
||||||
if _, declared := got["hello-web.server"]; declared {
|
|
||||||
t.Fatal("the left-out module's container is still declared")
|
|
||||||
}
|
|
||||||
if _, declared := got["distribution.store"]; !declared {
|
|
||||||
t.Fatal("the rest of the machine was not declared")
|
|
||||||
}
|
|
||||||
if left := r.LeftOut(with.Settings, false); len(left) != 1 || left["hello-web"] == "" {
|
|
||||||
t.Fatalf("the judgement a plan reads differs from what compose did: %v", left)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A taken container keeps a found network by a per-machine setting (novox/hq ADR 0163, rule 4):
|
|
||||||
// on an adopted machine only, for a container the module declares, and it reaches the container's
|
|
||||||
// declaration as the networks it also joins.
|
|
||||||
func TestAKeptNetworkReachesTheContainerOnAnAdoptedMachineOnly(t *testing.T) {
|
|
||||||
r := anAdoptedAnchor()
|
|
||||||
keep := SettingsBy{"hello-web": {{From: "anchor",
|
|
||||||
Values: map[string]any{NetworksSetting: map[string]any{"server": []any{"predecessor_default"}}}}}}
|
|
||||||
|
|
||||||
with := anchorRendering(true)
|
|
||||||
with.Settings = keep
|
|
||||||
composed, err := r.Compose(with)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(composed.LeftOut) != 0 {
|
|
||||||
t.Fatalf("a kept network left a module out: %v", composed.LeftOut)
|
|
||||||
}
|
|
||||||
server := byID(composed.Resources)["hello-web.server"]
|
|
||||||
networks, _ := server["networks"].([]any)
|
|
||||||
if len(networks) != 1 || networks[0] != "predecessor_default" {
|
|
||||||
t.Fatalf("the container does not join the kept network: %v", server)
|
|
||||||
}
|
|
||||||
if _, has := byID(composed.Resources)["distribution.store"]["networks"]; has {
|
|
||||||
t.Fatal("another container joins a network nobody kept for it")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Converged, the setting reaches nothing it was for, and the module is left out saying so.
|
|
||||||
with = anchorRendering(false)
|
|
||||||
with.Settings = keep
|
|
||||||
composed, err = r.Compose(with)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if why := composed.LeftOut["hello-web"]; !strings.Contains(why, "anchor is converged") {
|
|
||||||
t.Fatalf("a kept network on a converged machine: %v", composed.LeftOut)
|
|
||||||
}
|
|
||||||
|
|
||||||
web := r.Modules[4]
|
|
||||||
for _, c := range []struct {
|
|
||||||
name string
|
|
||||||
layer Layer
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{"mesh-wide", Layer{From: MeshWideLayer, Values: map[string]any{NetworksSetting: map[string]any{"server": []any{"x"}}}},
|
|
||||||
"a found network is a fact about one machine"},
|
|
||||||
{"an unknown container", Layer{From: "anchor", Values: map[string]any{NetworksSetting: map[string]any{"db": []any{"x"}}}},
|
|
||||||
`names the container "db", which it does not declare`},
|
|
||||||
{"not a list", Layer{From: "anchor", Values: map[string]any{NetworksSetting: map[string]any{"server": "x"}}},
|
|
||||||
"is a list of network names"},
|
|
||||||
{"not a network name", Layer{From: "anchor", Values: map[string]any{NetworksSetting: map[string]any{"server": []any{"a/b"}}}},
|
|
||||||
"which is not a network name"},
|
|
||||||
} {
|
|
||||||
_, err := KeptNetworks(web, []Layer{c.layer}, true)
|
|
||||||
if err == nil || !strings.Contains(err.Error(), c.want) {
|
|
||||||
t.Errorf("%s: %v, want %q", c.name, err, c.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if kept, err := KeptNetworks(web, nil, false); err != nil || kept != nil {
|
|
||||||
t.Fatalf("no setting: %v %v", kept, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
// The vault's provision is one the controller itself dereferences — every minted credential is
|
|
||||||
// sealed with it — so it is delivered by a seat of the mesh's own, and a second provider is a second
|
|
||||||
// claimant refused by name rather than a candidate for a pin (novox/hq ADR 0161, issue 106).
|
|
||||||
func TestTheVaultsSeatDeliversSecret(t *testing.T) {
|
|
||||||
seat, known := SeatNamed("mesh-vault")
|
|
||||||
if !known {
|
|
||||||
t.Fatal("mesh-vault is not in the mesh's own set")
|
|
||||||
}
|
|
||||||
if seat.Scope != ScopeMesh || seat.Delivers != "secret" {
|
|
||||||
t.Fatalf("mesh-vault is %s-scoped and delivers %q; one per mesh, delivering secret", seat.Scope, seat.Delivers)
|
|
||||||
}
|
|
||||||
vault := Manifest{Module: "mesh-vault", Provides: []Offer{{Name: "secret", Scope: ScopeMesh}},
|
|
||||||
Claims: []Claim{{Name: "mesh-vault", Scope: ScopeMesh}}}
|
|
||||||
if err := CanHold(vault, seat); err != nil {
|
|
||||||
t.Fatalf("the vault, claiming its seat and providing secret, was refused: %v", err)
|
|
||||||
}
|
|
||||||
another := Manifest{Module: "other-vault", Provides: []Offer{{Name: "secret", Scope: ScopeMesh}}}
|
|
||||||
if err := CanHold(another, seat); err == nil {
|
|
||||||
t.Fatal("a provider of secret that does not claim the seat was allowed to hold it")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -91,31 +91,12 @@ var ControllerVerbs = []Verb{
|
|||||||
"module": "one module's name; every module when absent",
|
"module": "one module's name; every module when absent",
|
||||||
"log": "a build's id (as `builds` lists it): print what the build machine said, line by line",
|
"log": "a build's id (as `builds` lists it): print what the build machine said, line by line",
|
||||||
}, nil)},
|
}, nil)},
|
||||||
{Name: "plans", Description: "What the last merges produced and where each stands (novox/hq ADR 0162): " +
|
|
||||||
"the tiers, the tier a plan is at, what it waits for and since when; one plan whole, given its id.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"id": "a plan's id (as `plans` lists them): that plan, tier by tier",
|
|
||||||
"stop": "a plan's id: stop it — what was asked still builds, nothing further is asked",
|
|
||||||
"repository": "owner/repository: the plan a merge there would produce, saving nothing (what-if); with paths or modules",
|
|
||||||
"paths": "with repository: the files the merge would change, comma-separated, from the repository's root",
|
|
||||||
"modules": "with repository: or the modules it would change, comma-separated",
|
|
||||||
}, nil)},
|
|
||||||
{Name: "plan", Description: "What one machine would run, and why: the declaration the mesh would send it.",
|
{Name: "plan", Description: "What one machine would run, and why: the declaration the mesh would send it.",
|
||||||
Input: schema(map[string]string{"node": "the machine's name"}, []string{"node"})},
|
Input: schema(map[string]string{"node": "the machine's name"}, []string{"node"})},
|
||||||
{Name: "assign", Description: "Put a module on a machine. Refused with the mesh's own words when it cannot resolve there.",
|
{Name: "assign", Description: "Put a module on a machine. Refused with the mesh's own words when it cannot resolve there.",
|
||||||
Input: schema(map[string]string{"node": "the machine's name", "module": "the module's name"}, []string{"node", "module"})},
|
Input: schema(map[string]string{"node": "the machine's name", "module": "the module's name"}, []string{"node", "module"})},
|
||||||
{Name: "unassign", Description: "Take a module off a machine.",
|
{Name: "unassign", Description: "Take a module off a machine.",
|
||||||
Input: schema(map[string]string{"node": "the machine's name", "module": "the module's name"}, []string{"node", "module"})},
|
Input: schema(map[string]string{"node": "the machine's name", "module": "the module's name"}, []string{"node", "module"})},
|
||||||
{Name: "pin", Description: "Tell a machine which provider answers a provision for it — the module, and the node " +
|
|
||||||
"it runs on, both. Asked for when more than one could answer; the refusal lists them.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"node": "the machine's name",
|
|
||||||
"provision": "the provision, as the consumer requires it",
|
|
||||||
"from": "the node the chosen provider runs on",
|
|
||||||
"module": "the module providing it there",
|
|
||||||
}, []string{"node", "provision", "from", "module"})},
|
|
||||||
{Name: "unpin", Description: "Take that choice back, putting the question to the mesh again.",
|
|
||||||
Input: schema(map[string]string{"node": "the machine's name", "provision": "the provision"}, []string{"node", "provision"})},
|
|
||||||
{Name: "push", Description: "Send a machine everything it should be — or every machine that is behind, when no machine is named.",
|
{Name: "push", Description: "Send a machine everything it should be — or every machine that is behind, when no machine is named.",
|
||||||
Input: schema(map[string]string{"node": "the machine's name; every machine behind when absent"}, nil)},
|
Input: schema(map[string]string{"node": "the machine's name; every machine behind when absent"}, nil)},
|
||||||
{Name: "rotate", Description: "Replace a credential. A pair credential, by provision (and a consuming machine, " +
|
{Name: "rotate", Description: "Replace a credential. A pair credential, by provision (and a consuming machine, " +
|
||||||
@@ -129,46 +110,15 @@ var ControllerVerbs = []Verb{
|
|||||||
"module": "an own secret: the module",
|
"module": "an own secret: the module",
|
||||||
"secret": "an own secret: its name in the module's definition",
|
"secret": "an own secret: its name in the module's definition",
|
||||||
}, nil)},
|
}, nil)},
|
||||||
{Name: "issue", Description: "Give a module on a machine its account on the bus: minted, and sealed to the " +
|
|
||||||
"machine as the module's own secret named broker, read at the next push of that machine. For a module " +
|
|
||||||
"whose definition declares that secret; refused with the reason otherwise. Issued again, it replaces the account.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"node": "the machine that runs the module",
|
|
||||||
"module": "the module's name",
|
|
||||||
}, []string{"node", "module"})},
|
|
||||||
{Name: "token", Description: "Issue a one-time token for a machine to join with. Give the public half of the " +
|
|
||||||
"tunnel key the machine made (`nox-mesh-host key`): the machine is given its address and made a peer of " +
|
|
||||||
"the hub, and joins through the tunnel. The token is shown once, in the answer.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"node": "a machine the mesh already has a record for",
|
|
||||||
"new": "or the name of a machine to create the record for",
|
|
||||||
"overlay_key": "the public half of the machine's tunnel key",
|
|
||||||
"for": "how long it may be used, as a duration (default 1h)",
|
|
||||||
"adopted": "\"true\" when the machine is in use and joins adopted",
|
|
||||||
}, nil)},
|
|
||||||
{Name: "settings", Description: "Set what an assignment is configured with: a module's settings for the whole mesh, " +
|
|
||||||
"or for one machine. Replaces that layer whole — what it does not name, it no longer sets — and takes effect " +
|
|
||||||
"at the next push. With clear, removes the layer and the module is back to what its definition says.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"module": "the module's name",
|
|
||||||
"values": "the settings as a JSON object, for set",
|
|
||||||
"node": "one machine; the whole mesh when absent",
|
|
||||||
"clear": "\"true\" to remove the layer instead of setting it",
|
|
||||||
}, []string{"module"})},
|
|
||||||
{Name: "command", Description: "Run one command line of the controller's own, as you would type it at its " +
|
|
||||||
"shell — `node account g14 jochen`, `node show ace`, `module list` — and answer what it printed. The " +
|
|
||||||
"generic verb beside the named ones (novox/hq ADR 0154): everything the binary can do, without a verb " +
|
|
||||||
"per command. Any node may call any tool (ADR 0175), so nothing is held back here.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"command": "the command line, as the controller's binary takes it; quotes group a word with spaces",
|
|
||||||
}, []string{"command"})},
|
|
||||||
{Name: "build", Description: "Have the build machine build a repository. Answers at once with the build's id: " +
|
{Name: "build", Description: "Have the build machine build a repository. Answers at once with the build's id: " +
|
||||||
"`builds` with that id follows it line by line, and the module is registered when the outcome comes.",
|
"`builds` with that id follows it line by line, and the module is registered when the outcome comes.",
|
||||||
Input: schema(map[string]string{
|
Input: schema(map[string]string{
|
||||||
|
"on": "instead of a repository: a module whose artifacts others stand on; every module built on it is rebuilt (the rebuild a changed base needs)",
|
||||||
|
"behind": "instead of a repository: \"yes\" rebuilds every module the mesh holds older than its source has",
|
||||||
"repository": "the repository's URL, or its path on the forge holding the git seat (owner/name)",
|
"repository": "the repository's URL, or its path on the forge holding the git seat (owner/name)",
|
||||||
"path": "the module's directory inside it (optional)",
|
"path": "the module's directory inside it (optional)",
|
||||||
"ref": "the branch, tag or commit to build (optional)",
|
"ref": "the branch, tag or commit to build (optional)",
|
||||||
}, []string{"repository"})},
|
}, nil)},
|
||||||
}
|
}
|
||||||
|
|
||||||
// schema is a JSON schema for an object of string properties, which is every argument the verbs
|
// schema is a JSON schema for an object of string properties, which is every argument the verbs
|
||||||
@@ -185,22 +135,7 @@ func schema(properties map[string]string, required []string) map[string]any {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// unpromised is what a claim says it serves and the seat's protocol never promised.
|
// unservedVerbs is what a seat promises and a claimant's `tools` does not answer.
|
||||||
func unpromised(serves []string, promised []Verb) []string {
|
|
||||||
has := map[string]bool{}
|
|
||||||
for _, v := range promised {
|
|
||||||
has[v.Name] = true
|
|
||||||
}
|
|
||||||
var extra []string
|
|
||||||
for _, s := range serves {
|
|
||||||
if !has[s] {
|
|
||||||
extra = append(extra, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return extra
|
|
||||||
}
|
|
||||||
|
|
||||||
// unservedVerbs is what a seat promises and a claimant's offer for it does not answer.
|
|
||||||
func unservedVerbs(tools []string, promised []Verb) []string {
|
func unservedVerbs(tools []string, promised []Verb) []string {
|
||||||
has := map[string]bool{}
|
has := map[string]bool{}
|
||||||
for _, t := range tools {
|
for _, t := range tools {
|
||||||
|
|||||||
@@ -164,115 +164,6 @@ type Held struct {
|
|||||||
Since time.Time `json:"since"`
|
Since time.Time `json:"since"`
|
||||||
Changed string `json:"changed,omitempty"`
|
Changed string `json:"changed,omitempty"`
|
||||||
Kept string `json:"kept,omitempty"`
|
Kept string `json:"kept,omitempty"`
|
||||||
// Facts is what a take compares (novox/hq ADR 0163), as the host reported it: for a found
|
|
||||||
// container its image and the image's date, the networks and their other members, mounts and
|
|
||||||
// ports, beside the declared image, ports and volumes, and whether the declared image is the
|
|
||||||
// older; for a found file whether the declared content differs and how.
|
|
||||||
Facts map[string]any `json:"facts,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// A Stray is a container a machine runs that the mesh neither wrote nor holds (ADR 0163).
|
|
||||||
type Stray struct {
|
|
||||||
Kind string `json:"kind"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Detail string `json:"detail,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// A Filter is one place on a machine that refuses traffic, with its owner (novox/hq ADR 0168).
|
|
||||||
type Filter struct {
|
|
||||||
Where string `json:"where"`
|
|
||||||
Owner string `json:"owner"`
|
|
||||||
Refuses string `json:"refuses"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Owners of a filter, as the host names them (ADR 0168).
|
|
||||||
const (
|
|
||||||
FilterMesh = "mesh"
|
|
||||||
FilterFoundFirewall = "found-firewall"
|
|
||||||
FilterRuntime = "runtime"
|
|
||||||
FilterBan = "ban"
|
|
||||||
FilterOther = "other"
|
|
||||||
)
|
|
||||||
|
|
||||||
// FoundFirewall is the state of a converged machine's found firewall (ADR 0168): in force now or
|
|
||||||
// not, and how it came to be inactive.
|
|
||||||
type FoundFirewall struct {
|
|
||||||
Kind string `json:"kind"`
|
|
||||||
Active bool `json:"active"`
|
|
||||||
RetiredBy string `json:"retired_by,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filtering is what a machine last said filters it (ADR 0168).
|
|
||||||
type Filtering struct {
|
|
||||||
Filters []Filter
|
|
||||||
FoundFirewall *FoundFirewall
|
|
||||||
}
|
|
||||||
|
|
||||||
// Alone is whether the machine is filtered by the mesh alone: nothing in its list but the mesh's
|
|
||||||
// own, the runtime's plumbing and bans, and no found firewall in force.
|
|
||||||
func (f Filtering) Alone() bool {
|
|
||||||
for _, x := range f.Filters {
|
|
||||||
if x.Owner == FilterOther || x.Owner == FilterFoundFirewall {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return f.FoundFirewall == nil || !f.FoundFirewall.Active
|
|
||||||
}
|
|
||||||
|
|
||||||
// Others is every filter that is neither the mesh's, the runtime's nor a ban.
|
|
||||||
func (f Filtering) Others() []Filter {
|
|
||||||
var out []Filter
|
|
||||||
for _, x := range f.Filters {
|
|
||||||
if x.Owner == FilterOther || x.Owner == FilterFoundFirewall {
|
|
||||||
out = append(out, x)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecordFiltering keeps what a machine last said filters it, replacing what was there (ADR 0168).
|
|
||||||
func (i *Inventory) RecordFiltering(ctx context.Context, nodeID string, filters []Filter, found *FoundFirewall) error {
|
|
||||||
raw, err := json.Marshal(nonNil(filters))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var foundRaw any
|
|
||||||
if found != nil {
|
|
||||||
b, err := json.Marshal(found)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
foundRaw = string(b)
|
|
||||||
}
|
|
||||||
_, err = i.store.Pool().Exec(ctx,
|
|
||||||
`update node set filters = $2, found_firewall = $3 where id = $1`, nodeID, raw, foundRaw)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// FilteringOf is what a machine last said filters it; empty for a machine that never said.
|
|
||||||
func (i *Inventory) FilteringOf(ctx context.Context, name string) (Filtering, error) {
|
|
||||||
var filtersRaw, foundRaw []byte
|
|
||||||
err := i.store.Pool().QueryRow(ctx,
|
|
||||||
`select filters, found_firewall from node where name = $1`, name).Scan(&filtersRaw, &foundRaw)
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return Filtering{}, fmt.Errorf("%w: %s", ErrNoSuchNode, name)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return Filtering{}, err
|
|
||||||
}
|
|
||||||
var out Filtering
|
|
||||||
if len(filtersRaw) > 0 {
|
|
||||||
if err := json.Unmarshal(filtersRaw, &out.Filters); err != nil {
|
|
||||||
return Filtering{}, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(foundRaw) > 0 {
|
|
||||||
out.FoundFirewall = &FoundFirewall{}
|
|
||||||
if err := json.Unmarshal(foundRaw, out.FoundFirewall); err != nil {
|
|
||||||
return Filtering{}, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reach is one thing reachable on an adopted node: a listening socket or a published port.
|
// Reach is one thing reachable on an adopted node: a listening socket or a published port.
|
||||||
@@ -290,8 +181,6 @@ type Adoption struct {
|
|||||||
Held []Held
|
Held []Held
|
||||||
Firewall string
|
Firewall string
|
||||||
Reachable []Reach
|
Reachable []Reach
|
||||||
// Strays is what the machine runs that nobody asked for, as last reported (ADR 0163).
|
|
||||||
Strays []Stray
|
|
||||||
// At is when it said so; zero when it never has.
|
// At is when it said so; zero when it never has.
|
||||||
At time.Time
|
At time.Time
|
||||||
}
|
}
|
||||||
@@ -300,16 +189,6 @@ type Adoption struct {
|
|||||||
// question is the machine as it is now.
|
// question is the machine as it is now.
|
||||||
func (i *Inventory) RecordAdoption(ctx context.Context, node string, held []Held, firewall string,
|
func (i *Inventory) RecordAdoption(ctx context.Context, node string, held []Held, firewall string,
|
||||||
reachable []Reach) error {
|
reachable []Reach) error {
|
||||||
return i.RecordAdoptionWithStrays(ctx, node, held, firewall, reachable, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecordAdoptionWithStrays is RecordAdoption with what the machine says strays on it (ADR 0163).
|
|
||||||
func (i *Inventory) RecordAdoptionWithStrays(ctx context.Context, node string, held []Held, firewall string,
|
|
||||||
reachable []Reach, strays []Stray) error {
|
|
||||||
straysRaw, err := json.Marshal(nonNil(strays))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
heldRaw, err := json.Marshal(nonNil(held))
|
heldRaw, err := json.Marshal(nonNil(held))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -319,9 +198,9 @@ func (i *Inventory) RecordAdoptionWithStrays(ctx context.Context, node string, h
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err = i.store.Pool().Exec(ctx,
|
_, err = i.store.Pool().Exec(ctx,
|
||||||
`update node set held = $2, firewall = nullif($3, ''), reachable = $4, strays = $5,
|
`update node set held = $2, firewall = nullif($3, ''), reachable = $4,
|
||||||
adoption_reported = now(), last_seen = now()
|
adoption_reported = now(), last_seen = now()
|
||||||
where id = $1`, node, heldRaw, firewall, reachRaw, straysRaw)
|
where id = $1`, node, heldRaw, firewall, reachRaw)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,12 +213,12 @@ func nonNil[T any](s []T) []T {
|
|||||||
|
|
||||||
// AdoptionOf is what a node last reported about adoption.
|
// AdoptionOf is what a node last reported about adoption.
|
||||||
func (i *Inventory) AdoptionOf(ctx context.Context, name string) (Adoption, error) {
|
func (i *Inventory) AdoptionOf(ctx context.Context, name string) (Adoption, error) {
|
||||||
var heldRaw, reachRaw, straysRaw []byte
|
var heldRaw, reachRaw []byte
|
||||||
var firewall *string
|
var firewall *string
|
||||||
var at *time.Time
|
var at *time.Time
|
||||||
err := i.store.Pool().QueryRow(ctx,
|
err := i.store.Pool().QueryRow(ctx,
|
||||||
`select held, firewall, reachable, adoption_reported, strays from node where name = $1`, name).
|
`select held, firewall, reachable, adoption_reported from node where name = $1`, name).
|
||||||
Scan(&heldRaw, &firewall, &reachRaw, &at, &straysRaw)
|
Scan(&heldRaw, &firewall, &reachRaw, &at)
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return Adoption{}, fmt.Errorf("%w: %s", ErrNoSuchNode, name)
|
return Adoption{}, fmt.Errorf("%w: %s", ErrNoSuchNode, name)
|
||||||
}
|
}
|
||||||
@@ -358,11 +237,6 @@ func (i *Inventory) AdoptionOf(ctx context.Context, name string) (Adoption, erro
|
|||||||
return Adoption{}, err
|
return Adoption{}, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(straysRaw) > 0 {
|
|
||||||
if err := json.Unmarshal(straysRaw, &out.Strays); err != nil {
|
|
||||||
return Adoption{}, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(reachRaw) > 0 {
|
if len(reachRaw) > 0 {
|
||||||
if err := json.Unmarshal(reachRaw, &out.Reachable); err != nil {
|
if err := json.Unmarshal(reachRaw, &out.Reachable); err != nil {
|
||||||
return Adoption{}, err
|
return Adoption{}, err
|
||||||
|
|||||||
@@ -49,8 +49,7 @@ func (i *Inventory) BusRecords(ctx context.Context) (broker.Records, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
out := broker.Records{Assigned: map[string][]broker.Declared{}, People: map[string][]string{},
|
out := broker.Records{Assigned: map[string][]broker.Declared{}, People: map[string][]string{}}
|
||||||
Interchangeable: map[string]bool{}}
|
|
||||||
for _, n := range nodes {
|
for _, n := range nodes {
|
||||||
out.Nodes = append(out.Nodes, n.Name)
|
out.Nodes = append(out.Nodes, n.Name)
|
||||||
modules, err := i.Assigned(ctx, n.Name)
|
modules, err := i.Assigned(ctx, n.Name)
|
||||||
@@ -73,9 +72,6 @@ func (i *Inventory) BusRecords(ctx context.Context) (broker.Records, error) {
|
|||||||
"be derived", module, n.Name)
|
"be derived", module, n.Name)
|
||||||
}
|
}
|
||||||
out.Assigned[n.Name] = append(out.Assigned[n.Name], declaredFor(m, seats))
|
out.Assigned[n.Name] = append(out.Assigned[n.Name], declaredFor(m, seats))
|
||||||
if m.Instances == catalogue.InstancesInterchangeable {
|
|
||||||
out.Interchangeable[m.Module] = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -605,12 +605,6 @@ func (i *Inventory) SetSettings(ctx context.Context, nodeName, module string, va
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Judged here, against the module's current definition, before it is kept (novox/hq ADR 0163,
|
|
||||||
// rule 6): a setting that cannot compose is refused where it is set, naming the node, the
|
|
||||||
// module, the layer and the key — never stored to refuse the whole machine where it is read.
|
|
||||||
if err := i.judgeSettings(ctx, nodeName, module, values); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if nodeName == "" {
|
if nodeName == "" {
|
||||||
// A port is a fact about one machine (novox/hq ADR 0100). Refused here, in composition's
|
// A port is a fact about one machine (novox/hq ADR 0100). Refused here, in composition's
|
||||||
// words: stored, it refuses every node running the module at composition, and the mesh
|
// words: stored, it refuses every node running the module at composition, and the mesh
|
||||||
@@ -667,50 +661,6 @@ func (i *Inventory) SetSettings(ctx context.Context, nodeName, module string, va
|
|||||||
return tx.Commit(ctx)
|
return tx.Commit(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// judgeSettings composes a layer somebody is about to store against the module's definition, with
|
|
||||||
// the mesh-wide layer under it when the layer is one node's, and refuses the first thing that
|
|
||||||
// cannot work (ADR 0163, rule 6). The same judgement composition makes; what passes here composes.
|
|
||||||
func (i *Inventory) judgeSettings(ctx context.Context, nodeName, module string, values map[string]any) error {
|
|
||||||
m, err := i.declared(ctx, module)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("%w: %s", ErrNoSuchModule, module)
|
|
||||||
}
|
|
||||||
where, from := "the mesh", catalogue.MeshWideLayer
|
|
||||||
adopted := false
|
|
||||||
var layers []catalogue.Layer
|
|
||||||
if nodeName != "" {
|
|
||||||
node, err := i.NodeByName(ctx, nodeName)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
where, from, adopted = nodeName, nodeName, node.Adopted
|
|
||||||
var meshWide []byte
|
|
||||||
err = i.store.Pool().QueryRow(ctx,
|
|
||||||
`select values from settings where module = $1 and node is null`, module).Scan(&meshWide)
|
|
||||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if len(meshWide) > 0 {
|
|
||||||
var under map[string]any
|
|
||||||
if err := json.Unmarshal(meshWide, &under); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
layers = append(layers, catalogue.Layer{From: catalogue.MeshWideLayer, Values: under})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
layers = append(layers, catalogue.Layer{From: from, Values: values})
|
|
||||||
if err := catalogue.JudgeSettings(m, layers, adopted); err != nil {
|
|
||||||
return fmt.Errorf("refused: %s on %s cannot compose with the layer %q — %w", module, where, from, err)
|
|
||||||
}
|
|
||||||
// And a key that reaches nothing, refused here where somebody can still fix the spelling:
|
|
||||||
// stored, it would be a setting somebody believes they made.
|
|
||||||
if stray := catalogue.UnusedSettings(m, layers[len(layers)-1:]); len(stray) > 0 {
|
|
||||||
return fmt.Errorf("refused: %s on %s — these settings reach nothing:\n - %s", module, where,
|
|
||||||
strings.Join(stray, "\n - "))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// givenIn is the machine ports a node-level settings layer gives a module, software port →
|
// givenIn is the machine ports a node-level settings layer gives a module, software port →
|
||||||
// machine port (novox/hq ADR 0100). Nothing when the layer gives none; what is not a port is left
|
// machine port (novox/hq ADR 0100). Nothing when the layer gives none; what is not a port is left
|
||||||
// for composition to refuse in its own words.
|
// for composition to refuse in its own words.
|
||||||
@@ -891,28 +841,24 @@ func (i *Inventory) SettingsFor(ctx context.Context, nodeName, module string) ([
|
|||||||
return layers, rows.Err()
|
return layers, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// PinProvision records which provider a machine gets a provision from: a module, and the node it
|
// PinProvision records which node a machine gets a provision from.
|
||||||
// runs on — both, always (novox/hq #258). A provision comes from a module, and the same module on
|
|
||||||
// two machines is two answers, so neither half alone says which.
|
|
||||||
//
|
//
|
||||||
// Only needed when more than one could answer. Recordable before that, because a mesh with one
|
// Only needed when more than one node could answer. Recordable before that, because a mesh with
|
||||||
// database should not change where an existing machine gets its data the day a second arrives.
|
// one database should not change where an existing machine gets its data the day a second
|
||||||
func (i *Inventory) PinProvision(ctx context.Context, nodeName, provision, providerNode, module string) error {
|
// arrives.
|
||||||
if strings.TrimSpace(module) == "" {
|
func (i *Inventory) PinProvision(ctx context.Context, nodeName, provision, provider string) error {
|
||||||
return fmt.Errorf("a pin names the module providing %q as well as the node it runs on", provision)
|
|
||||||
}
|
|
||||||
node, err := i.NodeByName(ctx, nodeName)
|
node, err := i.NodeByName(ctx, nodeName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
from, err := i.NodeByName(ctx, providerNode)
|
from, err := i.NodeByName(ctx, provider)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err = i.store.Pool().Exec(ctx,
|
_, err = i.store.Pool().Exec(ctx,
|
||||||
`insert into provision_pin (node, name, provider, module) values ($1, $2, $3, $4)
|
`insert into provision_pin (node, name, provider) values ($1, $2, $3)
|
||||||
on conflict (node, name) do update set provider = excluded.provider, module = excluded.module, pinned_at = now()`,
|
on conflict (node, name) do update set provider = excluded.provider, pinned_at = now()`,
|
||||||
node.ID, provision, from.ID, module)
|
node.ID, provision, from.ID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -933,28 +879,27 @@ func (i *Inventory) UnpinProvision(ctx context.Context, nodeName, provision stri
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// PinsFor is what a node was told about where its provisions come from. A record from before a pin
|
// PinsFor is what a node was told about where its provisions come from.
|
||||||
// named the module carries the node alone; the resolver honours it while it is unambiguous.
|
func (i *Inventory) PinsFor(ctx context.Context, nodeName string) (map[string]string, error) {
|
||||||
func (i *Inventory) PinsFor(ctx context.Context, nodeName string) (map[string]catalogue.Chosen, error) {
|
|
||||||
node, err := i.NodeByName(ctx, nodeName)
|
node, err := i.NodeByName(ctx, nodeName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
rows, err := i.store.Pool().Query(ctx,
|
rows, err := i.store.Pool().Query(ctx,
|
||||||
`select p.name, n.name, coalesce(p.module, '') from provision_pin p join node n on n.id = p.provider
|
`select p.name, n.name from provision_pin p join node n on n.id = p.provider
|
||||||
where p.node = $1`, node.ID)
|
where p.node = $1`, node.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
out := map[string]catalogue.Chosen{}
|
out := map[string]string{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var name, provider, module string
|
var name, provider string
|
||||||
if err := rows.Scan(&name, &provider, &module); err != nil {
|
if err := rows.Scan(&name, &provider); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out[name] = catalogue.Chosen{Node: provider, Module: module}
|
out[name] = provider
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -385,19 +385,19 @@ func TestAPinSurvivesAndCanBeChanged(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := inv.PinProvision(ctx, "user", "postgres-database", "first", "postgres"); err != nil {
|
if err := inv.PinProvision(ctx, "user", "postgres-database", "first"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// Changing the answer replaces it rather than adding a second, or a machine would be told to
|
// Changing the answer replaces it rather than adding a second, or a machine would be told to
|
||||||
// use two databases and nothing would say which.
|
// use two databases and nothing would say which.
|
||||||
if err := inv.PinProvision(ctx, "user", "postgres-database", "second", "postgres"); err != nil {
|
if err := inv.PinProvision(ctx, "user", "postgres-database", "second"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
pins, err := inv.PinsFor(ctx, "user")
|
pins, err := inv.PinsFor(ctx, "user")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(pins) != 1 || pins["postgres-database"].Node != "second" || pins["postgres-database"].Module != "postgres" {
|
if len(pins) != 1 || pins["postgres-database"] != "second" {
|
||||||
t.Fatalf("got %v", pins)
|
t.Fatalf("got %v", pins)
|
||||||
}
|
}
|
||||||
if err := inv.UnpinProvision(ctx, "user", "postgres-database"); err != nil {
|
if err := inv.UnpinProvision(ctx, "user", "postgres-database"); err != nil {
|
||||||
@@ -422,7 +422,7 @@ func TestAPinGoesWhenTheProviderLeavesTheMesh(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := inv.PinProvision(ctx, "consumer", "postgres-database", "provider", "postgres"); err != nil {
|
if err := inv.PinProvision(ctx, "consumer", "postgres-database", "provider"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, err := inv.store.Pool().Exec(ctx, `delete from node where name = 'provider'`); err != nil {
|
if _, err := inv.store.Pool().Exec(ctx, `delete from node where name = 'provider'`); err != nil {
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
package inventory
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The kinds of edge in the catalogue's one dependency relation (novox/hq ADR 0162).
|
|
||||||
const (
|
|
||||||
// EdgeStandsOn: the module's artifact is built on the other's.
|
|
||||||
EdgeStandsOn = "stands-on"
|
|
||||||
// EdgePackages: the module's build reads the other's repository.
|
|
||||||
EdgePackages = "packages"
|
|
||||||
// EdgeBuiltBy: the module is built by the holder of the build-machine seat.
|
|
||||||
EdgeBuiltBy = "built-by"
|
|
||||||
// EdgeDeclared: the manifest's own `build.on`.
|
|
||||||
EdgeDeclared = "declared"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Edge is one dependency: From depends on To, in the way Kind says.
|
|
||||||
type Edge struct {
|
|
||||||
From string `json:"from"`
|
|
||||||
To string `json:"to"`
|
|
||||||
Kind string `json:"kind"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dependencies is the catalogue's dependency relation, whole: every module the mesh holds, with
|
|
||||||
// an edge to each module it depends on and the kind of dependency on the edge. One answer, so
|
|
||||||
// nothing else computes an edge (novox/hq ADR 0162) — the merge handler, `build --on` and the
|
|
||||||
// overview all read this.
|
|
||||||
//
|
|
||||||
// Four sources, one relation: a manifest's `build.on`; the artifacts the latest build was made
|
|
||||||
// against (an `artifact-store://<module>/…` reference is an edge to that module); the repositories
|
|
||||||
// the latest build read (an edge to the module whose source that is); and the build machine, which
|
|
||||||
// every source-built module is built by.
|
|
||||||
func (i *Inventory) Dependencies(ctx context.Context) ([]Edge, error) {
|
|
||||||
entries, err := i.Catalogued(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
against, err := i.BuiltAgainst(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
read, err := i.ReadRepositories(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return dependenciesOf(entries, against, read), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// dependenciesOf is Dependencies over what was read, so a test can hand it a catalogue.
|
|
||||||
func dependenciesOf(entries []Entry, against map[string][]string, read map[string][]ReadRepository) []Edge {
|
|
||||||
known := map[string]bool{}
|
|
||||||
byRepository := map[string][]string{}
|
|
||||||
var builders []string
|
|
||||||
for _, e := range entries {
|
|
||||||
name := e.Manifest.Module
|
|
||||||
known[name] = true
|
|
||||||
if r := repositoryKey(e.Source.Repository); r != "" {
|
|
||||||
byRepository[r] = append(byRepository[r], name)
|
|
||||||
}
|
|
||||||
if e.Manifest.ClaimsSeat("mesh-build-machine") {
|
|
||||||
builders = append(builders, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
seen := map[Edge]bool{}
|
|
||||||
var out []Edge
|
|
||||||
add := func(from, to, kind string) {
|
|
||||||
if from == to || !known[to] {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
e := Edge{From: from, To: to, Kind: kind}
|
|
||||||
if !seen[e] {
|
|
||||||
seen[e] = true
|
|
||||||
out = append(out, e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, e := range entries {
|
|
||||||
name := e.Manifest.Module
|
|
||||||
if e.Manifest.Build != nil {
|
|
||||||
for _, on := range e.Manifest.Build.On {
|
|
||||||
if on.Module != "" {
|
|
||||||
add(name, on.Module, EdgeDeclared)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, ref := range against[name] {
|
|
||||||
if rest, ok := strings.CutPrefix(ref, catalogue.ArtifactStoreScheme); ok {
|
|
||||||
if base, _, found := strings.Cut(rest, "/"); found {
|
|
||||||
add(name, base, EdgeStandsOn)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, r := range read[name] {
|
|
||||||
for _, other := range byRepository[repositoryKey(r.Repository)] {
|
|
||||||
add(name, other, EdgePackages)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if e.Source.Repository != "" {
|
|
||||||
for _, b := range builders {
|
|
||||||
add(name, b, EdgeBuiltBy)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Slice(out, func(a, b int) bool {
|
|
||||||
if out[a].From != out[b].From {
|
|
||||||
return out[a].From < out[b].From
|
|
||||||
}
|
|
||||||
if out[a].To != out[b].To {
|
|
||||||
return out[a].To < out[b].To
|
|
||||||
}
|
|
||||||
return out[a].Kind < out[b].Kind
|
|
||||||
})
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// repositoryKey is a repository as compared: lower-cased, without a trailing `.git`.
|
|
||||||
func repositoryKey(repository string) string {
|
|
||||||
return strings.ToLower(strings.TrimSuffix(strings.TrimSpace(repository), ".git"))
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
package inventory
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The catalogue's one dependency relation (novox/hq ADR 0162): four kinds of edge from one call.
|
|
||||||
func TestDependenciesAreOneRelationWithTheirKinds(t *testing.T) {
|
|
||||||
entry := func(name, repository string) Entry {
|
|
||||||
return Entry{Manifest: catalogue.Manifest{Module: name}, Source: Source{Repository: repository}}
|
|
||||||
}
|
|
||||||
builder := entry("builder", "http://forge/novox/mesh-catalog.git")
|
|
||||||
builder.Manifest.Claims = []catalogue.Claim{{Name: "mesh-build-machine", Scope: catalogue.ScopeMesh}}
|
|
||||||
plugin := entry("shop-plugin", "http://forge/novox/mesh-catalog.git")
|
|
||||||
plugin.Manifest.Build = &catalogue.Build{On: []catalogue.BuildsOn{{Arg: "BASE", Module: "shop"}}}
|
|
||||||
entries := []Entry{
|
|
||||||
entry("mesh-tools", "http://forge/novox/mesh-tools.git"),
|
|
||||||
entry("shop", "http://forge/novox/mesh-catalog.git"),
|
|
||||||
plugin,
|
|
||||||
builder,
|
|
||||||
entry("mesh-controller", "http://forge/novox/mesh-controller.git"),
|
|
||||||
entry("route-proxy", "http://forge/novox/mesh-catalog.git"),
|
|
||||||
{Manifest: catalogue.Manifest{Module: "hand-made"}},
|
|
||||||
}
|
|
||||||
against := map[string][]string{
|
|
||||||
"shop": {catalogue.ArtifactStoreScheme + "mesh-tools/runtime@sha256:a"},
|
|
||||||
"builder": {catalogue.ArtifactStoreScheme + "mesh-tools/runtime@sha256:a"},
|
|
||||||
}
|
|
||||||
read := map[string][]ReadRepository{
|
|
||||||
"route-proxy": {{Repository: "http://forge/novox/mesh-controller.git", Ref: "main"}},
|
|
||||||
}
|
|
||||||
got := dependenciesOf(entries, against, read)
|
|
||||||
has := func(from, to, kind string) bool {
|
|
||||||
for _, e := range got {
|
|
||||||
if e == (Edge{From: from, To: to, Kind: kind}) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, want := range []Edge{
|
|
||||||
{"shop", "mesh-tools", EdgeStandsOn},
|
|
||||||
{"builder", "mesh-tools", EdgeStandsOn},
|
|
||||||
{"shop-plugin", "shop", EdgeDeclared},
|
|
||||||
{"route-proxy", "mesh-controller", EdgePackages},
|
|
||||||
{"shop", "builder", EdgeBuiltBy},
|
|
||||||
{"mesh-controller", "builder", EdgeBuiltBy},
|
|
||||||
{"mesh-tools", "builder", EdgeBuiltBy},
|
|
||||||
} {
|
|
||||||
if !has(want.From, want.To, want.Kind) {
|
|
||||||
t.Errorf("missing %+v in %+v", want, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if has("builder", "builder", EdgeBuiltBy) {
|
|
||||||
t.Error("the builder is not built by itself")
|
|
||||||
}
|
|
||||||
for _, e := range got {
|
|
||||||
if e.From == "hand-made" {
|
|
||||||
t.Errorf("a module with no source depends on nothing: %+v", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -31,11 +31,8 @@ func TestRegisteringAModuleAgainKeepsWhatTheMeshHoldsForIt(t *testing.T) {
|
|||||||
if err := inv.RecordSealingKey(ctx, node.ID, key); err != nil {
|
if err := inv.RecordSealingKey(ctx, node.ID, key); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// A mergeable file, so any setting composes (novox/hq ADR 0163, rule 6: a setting is judged
|
|
||||||
// where it is stored).
|
|
||||||
m := catalogue.Manifest{Module: "step-ca", Version: "1",
|
m := catalogue.Manifest{Module: "step-ca", Version: "1",
|
||||||
Provides: catalogue.Offers("acme-ca"), OwnSecrets: catalogue.OwnSecrets{"password": {Path: "/run/password"}},
|
Provides: catalogue.Offers("acme-ca"), OwnSecrets: catalogue.OwnSecrets{"password": {Path: "/run/password"}}}
|
||||||
Resources: []map[string]any{{"id": "conf", "type": "file", "path": "/etc/step-ca.json", "content": "{}", "merge": "json"}}}
|
|
||||||
if err := inv.RegisterModule(ctx, m, Source{}); err != nil {
|
if err := inv.RegisterModule(ctx, m, Source{}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -232,9 +229,5 @@ func TestAModuleStillAssignedRefusesBeforeAnythingAboutWhatItHolds(t *testing.T)
|
|||||||
// declares (novox/hq 04-ISSUES/078).
|
// declares (novox/hq 04-ISSUES/078).
|
||||||
func withOwnSecret(m catalogue.Manifest, name string) catalogue.Manifest {
|
func withOwnSecret(m catalogue.Manifest, name string) catalogue.Manifest {
|
||||||
m.OwnSecrets = catalogue.OwnSecrets{name: {Path: "/run/" + name}}
|
m.OwnSecrets = catalogue.OwnSecrets{name: {Path: "/run/" + name}}
|
||||||
// And a mergeable file, so any setting these tests store composes (novox/hq ADR 0163, rule 6:
|
|
||||||
// a setting is judged where it is stored).
|
|
||||||
m.Resources = append(m.Resources, map[string]any{"id": "conf", "type": "file",
|
|
||||||
"path": "/etc/" + m.Module + ".json", "content": "{}", "merge": "json"})
|
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
-- A pin names the module as well as the node (novox/hq #258).
|
|
||||||
--
|
|
||||||
-- 0008 said "not a module: the same module on two machines is two answers, and which machine is the
|
|
||||||
-- whole question". Half right. Two modules on one machine can both answer a provision — public-acme
|
|
||||||
-- and step-ca both offer acme-ca on novox — and then which *module* is the whole question, and a
|
|
||||||
-- node alone cannot ask it. The resolver, given a node that answered twice, took the last one listed.
|
|
||||||
--
|
|
||||||
-- A provider is a (node, module) pair (design 23), and a pin names the pair. Nullable, so a record
|
|
||||||
-- made before this was asked keeps meaning what it meant: honoured while that node answers once,
|
|
||||||
-- refused with the module asked for when it answers twice.
|
|
||||||
alter table provision_pin add column module text;
|
|
||||||
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
-- The records already made are completed where the mesh can tell: a pin naming a node on which
|
|
||||||
-- exactly one assigned module offers the provision (or is the module itself, for a requirement that
|
|
||||||
-- names a module) gets that module. A node that answers twice is left to say which — the resolver
|
|
||||||
-- refuses it with the module asked for, rather than this guessing on its behalf.
|
|
||||||
update provision_pin p
|
|
||||||
set module = sub.module
|
|
||||||
from (
|
|
||||||
select p2.node, p2.name, min(a.module) as module, count(distinct a.module) as answers
|
|
||||||
from provision_pin p2
|
|
||||||
join assignment a on a.node = p2.provider
|
|
||||||
join module m on m.name = a.module
|
|
||||||
where p2.module is null
|
|
||||||
and (a.module = p2.name
|
|
||||||
or exists (select 1
|
|
||||||
from jsonb_array_elements(coalesce(m.manifest -> 'provides', '[]'::jsonb)) e
|
|
||||||
where (case when jsonb_typeof(e) = 'string' then e #>> '{}' else e ->> 'name' end) = p2.name))
|
|
||||||
group by p2.node, p2.name
|
|
||||||
) sub
|
|
||||||
where sub.node = p.node and sub.name = p.name and sub.answers = 1;
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
-- A merge produces a tiered plan the mesh keeps (novox/hq ADR 0162): what the merge changed and
|
|
||||||
-- everything standing on it, sorted into tiers, each module's state, and the tier the plan is at.
|
|
||||||
-- Kept so a controller replaced mid-plan resumes it, and so `status` can say what a merge still
|
|
||||||
-- waits for.
|
|
||||||
create table release_plan (
|
|
||||||
id text primary key,
|
|
||||||
repository text not null,
|
|
||||||
commit_hash text not null,
|
|
||||||
created timestamptz not null default now(),
|
|
||||||
updated timestamptz not null default now(),
|
|
||||||
-- building: a tier's builds are asked; rolling: the tier is built and the machines are applying
|
|
||||||
-- what a later tier needs running; done; failed.
|
|
||||||
state text not null,
|
|
||||||
tier int not null default 0,
|
|
||||||
tiers jsonb not null,
|
|
||||||
modules jsonb not null,
|
|
||||||
note text not null default ''
|
|
||||||
);
|
|
||||||
create index release_plan_open on release_plan (created) where state in ('building', 'rolling');
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
-- What runs on a machine that the mesh neither wrote nor holds, as the host reports it with every
|
|
||||||
-- apply (novox/hq ADR 0163): a container left behind by a cutover is seen the day it is left.
|
|
||||||
alter table node add column strays jsonb;
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
-- What filters a machine, with owners, as the host reports it with every apply (novox/hq ADR 0168):
|
|
||||||
-- every table and chain that refuses traffic — the mesh's, the found firewall's, the runtime's own,
|
|
||||||
-- a ban, or other — so the mesh says truthfully what filters a converged machine and names what it
|
|
||||||
-- did not write. And the state of the firewall a converged machine was found with: in force now or
|
|
||||||
-- not, and who retired it.
|
|
||||||
alter table node add column filters jsonb;
|
|
||||||
alter table node add column found_firewall jsonb;
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
-- A token issued for a tunnel key (novox/hq ADR 0169).
|
|
||||||
--
|
|
||||||
-- A machine that joins through the tunnel makes its key first, and the token is issued for it: the
|
|
||||||
-- hub is told the key before the token is shown. So enrolment must take that key and no other — a
|
|
||||||
-- different one is a machine the hub does not know, offering a tunnel that would never answer. Null
|
|
||||||
-- for a token issued without one, which enrols as before.
|
|
||||||
alter table enrolment_token add column overlay_key text;
|
|
||||||
@@ -356,33 +356,6 @@ func (i *Inventory) Claim(ctx context.Context, secret, by string, again bool) (N
|
|||||||
return scanNode(i.store.Pool().QueryRow(ctx, `select `+nodeColumns+` from node where id = $1`, id))
|
return scanNode(i.store.Pool().QueryRow(ctx, `select `+nodeColumns+` from node where id = $1`, id))
|
||||||
}
|
}
|
||||||
|
|
||||||
// BindTokenToKey records the tunnel key a node's live token was issued for (novox/hq ADR 0169), so
|
|
||||||
// enrolment takes that key and no other. Refused when the node has no live token to bind: a key
|
|
||||||
// recorded against nothing would be a promise nothing keeps.
|
|
||||||
func (i *Inventory) BindTokenToKey(ctx context.Context, node, key string) error {
|
|
||||||
tag, err := i.store.Pool().Exec(ctx,
|
|
||||||
`update enrolment_token set overlay_key = $2
|
|
||||||
where node = $1 and redeemed is null and expires > now()`, node, key)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if tag.RowsAffected() == 0 {
|
|
||||||
return fmt.Errorf("no live token to issue for the tunnel key")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// TokenKey is the tunnel key a token was issued for, or empty when it was issued without one.
|
|
||||||
func (i *Inventory) TokenKey(ctx context.Context, secret string) (string, error) {
|
|
||||||
var key *string
|
|
||||||
err := i.store.Pool().QueryRow(ctx,
|
|
||||||
`select overlay_key from enrolment_token where secret = $1`, hashSecret(secret)).Scan(&key)
|
|
||||||
if err != nil || key == nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return *key, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Spend makes a claimed token used, only for the presenter holding the claim. The last write to the
|
// Spend makes a claimed token used, only for the presenter holding the claim. The last write to the
|
||||||
// store in an enrolment, so a token is spent exactly when the node it enrolled is complete. Spent
|
// store in an enrolment, so a token is spent exactly when the node it enrolled is complete. Spent
|
||||||
// again by the same presenter is not an error: an answer lost after the first spend.
|
// again by the same presenter is not an error: an answer lost after the first spend.
|
||||||
|
|||||||
@@ -42,11 +42,8 @@ func TestAPersonMayCallToolsAndNothingElse(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// The one tool, both ways it is addressed (novox/hq ADR 0159): to whichever instance
|
if len(perms.Publish) != 1 || perms.Publish[0] != "mesh.mod.mesh-catalog.tool.catalog_tools" {
|
||||||
// answers, and to the instance on one machine. Nothing else.
|
t.Errorf("ada may publish %v, which should be the one tool and nothing else", perms.Publish)
|
||||||
if len(perms.Publish) != 2 || perms.Publish[0] != "mesh.mod.mesh-catalog.tool.catalog_tools" ||
|
|
||||||
perms.Publish[1] != "mesh.mod.mesh-catalog.tool.catalog_tools.*" {
|
|
||||||
t.Errorf("ada may publish %v, which should be the one tool, both ways addressed, and nothing else", perms.Publish)
|
|
||||||
}
|
}
|
||||||
for _, s := range perms.Publish {
|
for _, s := range perms.Publish {
|
||||||
if strings.HasPrefix(s, "mesh.control") || strings.HasPrefix(s, "mesh.node") ||
|
if strings.HasPrefix(s, "mesh.control") || strings.HasPrefix(s, "mesh.node") ||
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
package inventory
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A pin made before it named the module (migration 0051, novox/hq #258): completed where the node it
|
|
||||||
// names answers once, left for a person where it answers twice.
|
|
||||||
|
|
||||||
func legacyPin(t *testing.T, inv *Inventory, node, provision, provider string) {
|
|
||||||
t.Helper()
|
|
||||||
_, err := inv.store.Pool().Exec(context.Background(),
|
|
||||||
`insert into provision_pin (node, name, provider)
|
|
||||||
select u.id, $2, p.id from node u, node p where u.name = $1 and p.name = $3`,
|
|
||||||
node, provision, provider)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func completeEarlierPins(t *testing.T, inv *Inventory) {
|
|
||||||
t.Helper()
|
|
||||||
sql, err := os.ReadFile("migrations/0051-a-pin-made-before-is-completed.sql")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := inv.store.Pool().Exec(context.Background(), string(sql)); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAPinMadeBeforeIsCompletedWhenTheNodeAnswersOnce(t *testing.T) {
|
|
||||||
inv := fresh(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
for _, n := range []string{"user", "provider"} {
|
|
||||||
if _, err := inv.AddNode(ctx, n); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, m := range []string{"postgres", "redis"} {
|
|
||||||
if err := inv.RegisterModule(ctx, manifest(m, []string{m + "-database"}, nil), Source{}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := inv.Assign(ctx, "provider", m); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
legacyPin(t, inv, "user", "postgres-database", "provider")
|
|
||||||
completeEarlierPins(t, inv)
|
|
||||||
pins, err := inv.PinsFor(ctx, "user")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got := pins["postgres-database"]; got.Node != "provider" || got.Module != "postgres" {
|
|
||||||
t.Fatalf("the record was not completed with the one module that answers: %+v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAPinMadeBeforeIsLeftOpenWhenTheNodeAnswersTwice(t *testing.T) {
|
|
||||||
inv := fresh(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
for _, n := range []string{"user", "provider"} {
|
|
||||||
if _, err := inv.AddNode(ctx, n); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, m := range []string{"public-acme", "step-ca"} {
|
|
||||||
if err := inv.RegisterModule(ctx, manifest(m, []string{"acme-ca"}, nil), Source{}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := inv.Assign(ctx, "provider", m); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
legacyPin(t, inv, "user", "acme-ca", "provider")
|
|
||||||
completeEarlierPins(t, inv)
|
|
||||||
pins, err := inv.PinsFor(ctx, "user")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got := pins["acme-ca"]; got.Node != "provider" || got.Module != "" {
|
|
||||||
t.Fatalf("a node that answers twice was guessed for: %+v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
package inventory
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A Plan is what a merge produces (novox/hq ADR 0162): the modules it changed and everything
|
|
||||||
// standing on them, sorted into tiers, each module's state, and the tier the plan is at. Kept in
|
|
||||||
// the store so a controller replaced mid-plan resumes it, and so `status` can say what a merge
|
|
||||||
// still waits for.
|
|
||||||
type Plan struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Repository string `json:"repository"`
|
|
||||||
Commit string `json:"commit"`
|
|
||||||
Created time.Time `json:"created"`
|
|
||||||
Updated time.Time `json:"updated"`
|
|
||||||
State string `json:"state"`
|
|
||||||
Tier int `json:"tier"`
|
|
||||||
Tiers [][]string `json:"tiers"`
|
|
||||||
Modules map[string]*PlanModule `json:"modules"`
|
|
||||||
Note string `json:"note,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// PlanModule is one module's state within a plan.
|
|
||||||
type PlanModule struct {
|
|
||||||
// State: asked, built, failed; empty for a module whose tier has not been asked yet.
|
|
||||||
State string `json:"state,omitempty"`
|
|
||||||
AskedAt *time.Time `json:"asked_at,omitempty"`
|
|
||||||
BuiltAt *time.Time `json:"built_at,omitempty"`
|
|
||||||
// SentAt is when the plan sent the machines running this module its new build, because a
|
|
||||||
// later tier is built by it (ADR 0163's gate): the reports that open the gate are the ones
|
|
||||||
// after this.
|
|
||||||
SentAt *time.Time `json:"sent_at,omitempty"`
|
|
||||||
Commit string `json:"commit,omitempty"`
|
|
||||||
Why string `json:"why,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// The states a plan passes through.
|
|
||||||
const (
|
|
||||||
PlanBuilding = "building"
|
|
||||||
PlanRolling = "rolling"
|
|
||||||
PlanDone = "done"
|
|
||||||
PlanFailed = "failed"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Open says whether the plan is still being worked.
|
|
||||||
func (p Plan) Open() bool { return p.State == PlanBuilding || p.State == PlanRolling }
|
|
||||||
|
|
||||||
// SavePlan writes a plan, new or changed, whole: the plan is small and read as one thing.
|
|
||||||
func (i *Inventory) SavePlan(ctx context.Context, p Plan) error {
|
|
||||||
tiers, err := json.Marshal(p.Tiers)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
modules, err := json.Marshal(p.Modules)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err = i.store.Pool().Exec(ctx,
|
|
||||||
`insert into release_plan (id, repository, commit_hash, created, updated, state, tier, tiers, modules, note)
|
|
||||||
values ($1, $2, $3, $4, now(), $5, $6, $7, $8, $9)
|
|
||||||
on conflict (id) do update set updated = now(), state = excluded.state, tier = excluded.tier,
|
|
||||||
tiers = excluded.tiers, modules = excluded.modules, note = excluded.note`,
|
|
||||||
p.ID, p.Repository, p.Commit, p.Created, p.State, p.Tier, tiers, modules, p.Note)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// OpenPlans is every plan still being worked, oldest first.
|
|
||||||
func (i *Inventory) OpenPlans(ctx context.Context) ([]Plan, error) {
|
|
||||||
return i.plans(ctx, `where state in ('building', 'rolling') order by created`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecentPlans is the last few plans, newest first, open or not — what the overview shows.
|
|
||||||
func (i *Inventory) RecentPlans(ctx context.Context, limit int) ([]Plan, error) {
|
|
||||||
return i.plans(ctx, fmt.Sprintf(`order by created desc limit %d`, limit))
|
|
||||||
}
|
|
||||||
|
|
||||||
// PlanByID is one plan.
|
|
||||||
func (i *Inventory) PlanByID(ctx context.Context, id string) (Plan, error) {
|
|
||||||
plans, err := i.plans(ctx, `where id = '`+id+`'`)
|
|
||||||
if err != nil {
|
|
||||||
return Plan{}, err
|
|
||||||
}
|
|
||||||
if len(plans) == 0 {
|
|
||||||
return Plan{}, fmt.Errorf("no plan %s", id)
|
|
||||||
}
|
|
||||||
return plans[0], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (i *Inventory) plans(ctx context.Context, tail string) ([]Plan, error) {
|
|
||||||
rows, err := i.store.Pool().Query(ctx,
|
|
||||||
`select id, repository, commit_hash, created, updated, state, tier, tiers, modules, note
|
|
||||||
from release_plan `+tail)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var out []Plan
|
|
||||||
for rows.Next() {
|
|
||||||
var p Plan
|
|
||||||
var tiers, modules []byte
|
|
||||||
if err := rows.Scan(&p.ID, &p.Repository, &p.Commit, &p.Created, &p.Updated, &p.State,
|
|
||||||
&p.Tier, &tiers, &modules, &p.Note); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(tiers, &p.Tiers); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(modules, &p.Modules); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if p.Modules == nil {
|
|
||||||
p.Modules = map[string]*PlanModule{}
|
|
||||||
}
|
|
||||||
out = append(out, p)
|
|
||||||
}
|
|
||||||
if errors.Is(rows.Err(), pgx.ErrNoRows) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
package inventory
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A plan is a record the mesh keeps and resumes (novox/hq ADR 0162): written whole, read back open,
|
|
||||||
// advanced, and gone from the open ones when done.
|
|
||||||
func TestAPlanIsKeptAdvancedAndResumedFromTheStore(t *testing.T) {
|
|
||||||
inv := ForTest(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
p := Plan{ID: "plan-1", Repository: "novox/mesh-tools", Commit: "abc", Created: time.Now().UTC(),
|
|
||||||
State: PlanBuilding, Tiers: [][]string{{"mesh-tools"}, {"builder"}, {"shop"}},
|
|
||||||
Modules: map[string]*PlanModule{"mesh-tools": {}, "builder": {}, "shop": {}}}
|
|
||||||
if err := inv.SavePlan(ctx, p); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
open, err := inv.OpenPlans(ctx)
|
|
||||||
if err != nil || len(open) != 1 || open[0].ID != "plan-1" || len(open[0].Tiers) != 3 {
|
|
||||||
t.Fatalf("the plan was not kept whole: %v %+v", err, open)
|
|
||||||
}
|
|
||||||
// Another controller picks it up where it was left: a tier advanced and a module built.
|
|
||||||
now := time.Now().UTC()
|
|
||||||
resumed := open[0]
|
|
||||||
resumed.Tier = 1
|
|
||||||
resumed.Modules["mesh-tools"].State = "built"
|
|
||||||
resumed.Modules["mesh-tools"].BuiltAt = &now
|
|
||||||
resumed.State = PlanRolling
|
|
||||||
resumed.Note = "tier 0 built; waiting for builder on anchor to be applied"
|
|
||||||
if err := inv.SavePlan(ctx, resumed); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
again, err := inv.PlanByID(ctx, "plan-1")
|
|
||||||
if err != nil || again.Tier != 1 || again.Modules["mesh-tools"].State != "built" || again.State != PlanRolling {
|
|
||||||
t.Fatalf("the advanced plan did not come back as left: %v %+v", err, again)
|
|
||||||
}
|
|
||||||
again.State = PlanDone
|
|
||||||
if err := inv.SavePlan(ctx, again); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if open, _ = inv.OpenPlans(ctx); len(open) != 0 {
|
|
||||||
t.Fatalf("a done plan is not open: %+v", open)
|
|
||||||
}
|
|
||||||
if recent, _ := inv.RecentPlans(ctx, 5); len(recent) != 1 || recent[0].State != PlanDone {
|
|
||||||
t.Fatalf("a done plan is still among the recent ones: %+v", recent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -15,16 +15,9 @@ func aNodeWithModules(t *testing.T, modules ...string) (*Inventory, string) {
|
|||||||
if _, err := inv.AddNode(ctx, "anchor"); err != nil {
|
if _, err := inv.AddNode(ctx, "anchor"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// Each publishes the port these tests give it a machine port for: a port given for one the
|
|
||||||
// module does not publish is refused where it is stored (novox/hq ADR 0163, rule 6).
|
|
||||||
publishes := map[string]string{"postgres": "5432", "another-database": "5432", "cache": "6379", "web": "8080"}
|
|
||||||
for _, m := range modules {
|
for _, m := range modules {
|
||||||
manifest := catalogue.Manifest{Module: m, Version: "1"}
|
if err := inv.RegisterModule(ctx,
|
||||||
if port, known := publishes[m]; known {
|
catalogue.Manifest{Module: m, Version: "1"}, Source{}); err != nil {
|
||||||
manifest.Resources = []map[string]any{{"id": "server", "type": "container", "name": m,
|
|
||||||
"image": "x", "ports": []any{port}}}
|
|
||||||
}
|
|
||||||
if err := inv.RegisterModule(ctx, manifest, Source{}); err != nil {
|
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -398,7 +398,7 @@ func (i *Inventory) AcceptSecretForModule(ctx context.Context, node, module, nam
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, own := m.OwnSecrets[name]; !own {
|
if _, own := m.OwnSecrets[name]; !own {
|
||||||
return fmt.Errorf("%s does not declare %q as an own secret; %s — a secret it requires from a provider is accepted with `--provider <node> [--local <name>]`, the value the running service already uses (novox/hq ADR 0163)", module, name, declaresOwn(m))
|
return fmt.Errorf("%s does not declare %q as an own secret; %s", module, name, declaresOwn(m))
|
||||||
}
|
}
|
||||||
key, err := i.SealingKeyOf(ctx, node)
|
key, err := i.SealingKeyOf(ctx, node)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -546,7 +546,7 @@ func (i *Inventory) RotateModuleSecret(ctx context.Context, node, module, name s
|
|||||||
}
|
}
|
||||||
own, declared := m.OwnSecrets[name]
|
own, declared := m.OwnSecrets[name]
|
||||||
if !declared {
|
if !declared {
|
||||||
return fmt.Errorf("%s does not declare %q as an own secret; %s — a secret it requires from a provider is accepted with `--provider <node> [--local <name>]`, the value the running service already uses (novox/hq ADR 0163)", module, name, declaresOwn(m))
|
return fmt.Errorf("%s does not declare %q as an own secret; %s", module, name, declaresOwn(m))
|
||||||
}
|
}
|
||||||
switch own.Taken {
|
switch own.Taken {
|
||||||
case catalogue.TakenAtStart:
|
case catalogue.TakenAtStart:
|
||||||
@@ -820,52 +820,3 @@ func (i *Inventory) SharedHolders(ctx context.Context, provider, providerModule,
|
|||||||
sort.Strings(out)
|
sort.Strings(out)
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SecretState is one secret a module holds on a machine, as a take compares it (novox/hq ADR
|
|
||||||
// 0163): its name, where it came from — made by the mesh or accepted from a person — and, for a
|
|
||||||
// credential the module requires from a provider, which node provides it and the local name it
|
|
||||||
// goes by where the module keeps several.
|
|
||||||
type SecretState struct {
|
|
||||||
Name string
|
|
||||||
// Local is the credential's name inside the module (ADR 0094); empty for an own secret or the
|
|
||||||
// ordinary one.
|
|
||||||
Local string
|
|
||||||
// Origin is OriginMade or OriginAccepted.
|
|
||||||
Origin string
|
|
||||||
// Provider is the node providing a required secret; empty for the module's own.
|
|
||||||
Provider string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Own says the secret is the module's own rather than one it requires from a provider.
|
|
||||||
func (s SecretState) Own() bool { return s.Provider == "" }
|
|
||||||
|
|
||||||
// SecretsOf is every secret a module holds on a machine: its own, and each credential it requires
|
|
||||||
// from a provider — with where each value came from. What a take reads to refuse minting over a
|
|
||||||
// service that already has one (ADR 0163, rule 2).
|
|
||||||
func (i *Inventory) SecretsOf(ctx context.Context, node, module string) ([]SecretState, error) {
|
|
||||||
record, err := i.NodeByName(ctx, node)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
rows, err := i.store.Pool().Query(ctx,
|
|
||||||
`select name, '' as local, origin, '' as provider from module_secret
|
|
||||||
where node = $1 and module = $2
|
|
||||||
union all
|
|
||||||
select s.name, s.local, s.origin, p.name from secret s
|
|
||||||
join node p on p.id = s.provider
|
|
||||||
where s.consumer = $1 and s.consumer_module = $2
|
|
||||||
order by 4, 1, 2`, record.ID, module)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var out []SecretState
|
|
||||||
for rows.Next() {
|
|
||||||
var s SecretState
|
|
||||||
if err := rows.Scan(&s.Name, &s.Local, &s.Origin, &s.Provider); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out = append(out, s)
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -934,47 +934,3 @@ func TestASharedCredentialIsOneValueSealedToEveryHolder(t *testing.T) {
|
|||||||
t.Fatalf("a consumer binding after an acceptance must be refused with the way out: %v", err)
|
t.Fatalf("a consumer binding after an acceptance must be refused with the way out: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SecretsOf is every secret a module holds on a machine with where each came from — what a take
|
|
||||||
// reads to refuse minting over a service that already has a value (novox/hq ADR 0163, rule 2).
|
|
||||||
func TestSecretsOfSaysEachSecretsOriginAndProvider(t *testing.T) {
|
|
||||||
inv, ctx := twoNodesWithKeys(t)
|
|
||||||
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "forge", Version: "1",
|
|
||||||
Requires: []string{"secret", "postgres-database"},
|
|
||||||
Secrets: map[string]string{"secret": "/run/secret", "postgres-database": "/run/pg"},
|
|
||||||
OwnSecrets: catalogue.OwnSecrets{"admin": {Path: "/run/admin"}}}, Source{}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := inv.SecretForModule(ctx, "consumer", "forge", "admin"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := inv.SecretFor(ctx, "postgres-database", "consumer", "forge", "provider", ""); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := inv.AcceptSecretForPair(ctx, "secret", "consumer", "forge", "provider", "", "hunter2"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
got, err := inv.SecretsOf(ctx, "consumer", "forge")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
want := []SecretState{
|
|
||||||
{Name: "admin", Origin: OriginMade},
|
|
||||||
{Name: "postgres-database", Origin: OriginMade, Provider: "provider"},
|
|
||||||
{Name: "secret", Origin: OriginAccepted, Provider: "provider"},
|
|
||||||
}
|
|
||||||
if len(got) != len(want) {
|
|
||||||
t.Fatalf("got %+v", got)
|
|
||||||
}
|
|
||||||
for i := range want {
|
|
||||||
if got[i] != want[i] {
|
|
||||||
t.Errorf("secret %d: got %+v, want %+v", i, got[i], want[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !got[0].Own() || got[1].Own() {
|
|
||||||
t.Error("own and required are not told apart")
|
|
||||||
}
|
|
||||||
if other, _ := inv.SecretsOf(ctx, "consumer", "gitea"); len(other) != 0 {
|
|
||||||
t.Fatalf("another module's secrets: %+v", other)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
package inventory
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A setting is judged where it is stored (novox/hq ADR 0163, rule 6): one that cannot compose with
|
|
||||||
// the module's definition is refused naming the node, the module, the layer and the key, and is not
|
|
||||||
// kept; one that reaches nothing is refused the same way.
|
|
||||||
func TestASettingIsJudgedWhereItIsStored(t *testing.T) {
|
|
||||||
inv := fresh(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
if _, err := inv.AddNode(ctx, "anchor"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
web := catalogue.Manifest{Module: "web", Version: "1",
|
|
||||||
Resources: []map[string]any{
|
|
||||||
{"id": "server", "type": "container", "name": "web", "image": "x", "ports": []any{"8080"}},
|
|
||||||
{"id": "conf", "type": "file", "path": "/etc/web.json", "content": "{}", "merge": "json"},
|
|
||||||
}}
|
|
||||||
plain := catalogue.Manifest{Module: "plain", Version: "1",
|
|
||||||
Resources: []map[string]any{{"id": "server", "type": "container", "name": "plain", "image": "x"}}}
|
|
||||||
for _, m := range []catalogue.Manifest{web, plain} {
|
|
||||||
if err := inv.RegisterModule(ctx, m, Source{}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err := inv.SetSettings(ctx, "anchor", "web", map[string]any{catalogue.PortsSetting: map[string]any{"9999": 10000}})
|
|
||||||
for _, want := range []string{"refused: web on anchor", `layer "anchor"`, "9999"} {
|
|
||||||
if err == nil || !strings.Contains(err.Error(), want) {
|
|
||||||
t.Errorf("a port the module does not publish: %v, want %q", err, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if layers, _ := inv.SettingsFor(ctx, "anchor", "web"); len(layers) != 0 {
|
|
||||||
t.Fatalf("the refused layer was stored: %v", layers)
|
|
||||||
}
|
|
||||||
// A key that reaches nothing is refused too, where the spelling can still be fixed; a module
|
|
||||||
// with a mergeable file takes any key.
|
|
||||||
err = inv.SetSettings(ctx, "", "plain", map[string]any{"colour": "blue"})
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "reach nothing") || !strings.Contains(err.Error(), `"colour"`) {
|
|
||||||
t.Fatalf("a stray key was stored: %v", err)
|
|
||||||
}
|
|
||||||
if err := inv.SetSettings(ctx, "", "web", map[string]any{"colour": "blue"}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
// The mesh-wide layer is under the node's when the node's is judged.
|
|
||||||
if err := inv.SetSettings(ctx, "anchor", "web", map[string]any{catalogue.PortsSetting: map[string]any{"8080": 10000}}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
// A kept network is for an adopted machine only (rule 4).
|
|
||||||
keep := map[string]any{catalogue.NetworksSetting: map[string]any{"server": []any{"predecessor_default"}}}
|
|
||||||
err = inv.SetSettings(ctx, "anchor", "plain", keep)
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "anchor is converged") {
|
|
||||||
t.Fatalf("a kept network on a converged machine was stored: %v", err)
|
|
||||||
}
|
|
||||||
if err := inv.SetAdopted(ctx, "anchor", true); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := inv.SetSettings(ctx, "anchor", "plain", keep); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := inv.SetSettings(ctx, "anchor", "nothing", keep); err == nil {
|
|
||||||
t.Fatal("a setting for a module the mesh does not know was stored")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -173,13 +173,7 @@ func (m *natsMachine) Take(ctx context.Context, do func(context.Context, Build))
|
|||||||
_ = msg.Term()
|
_ = msg.Term()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// A build outlives the acknowledgement window many times over; said while it runs,
|
|
||||||
// as the controller says it for its own long handlers, so the server neither hands
|
|
||||||
// the ask to a second machine nor counts the wait against its deliveries.
|
|
||||||
working := make(chan struct{})
|
|
||||||
go stillWorking(msg, working)
|
|
||||||
do(ctx, &natsBuild{request: request, msg: msg, on: m.on, js: m.js})
|
do(ctx, &natsBuild{request: request, msg: msg, on: m.on, js: m.js})
|
||||||
close(working)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/novox/mesh-controller/internal/broker"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/nats-io/nats.go"
|
"github.com/nats-io/nats.go"
|
||||||
@@ -146,24 +145,6 @@ func (b OverNATS) PublishSeatEvent(ctx context.Context, seat, event string, body
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// PublishMembership issues one assignment what it serves and reaches (novox/hq ADR 0160), last per
|
|
||||||
// subject, so the runtime that connects later reads the current one and one that is running follows.
|
|
||||||
// MembershipWait bounds how long issuing one membership may take. A publish the server refuses is
|
|
||||||
// never acknowledged, and a stream publish waits for its acknowledgement for as long as its
|
|
||||||
// context lives: on 2026-10-01 the daemon's own context was that long, and one refused membership
|
|
||||||
// held the controller's receive loop for good (novox/hq issue 185).
|
|
||||||
const MembershipWait = 10 * time.Second
|
|
||||||
|
|
||||||
func (b OverNATS) PublishMembership(ctx context.Context, node, module string, body []byte) error {
|
|
||||||
ctx, cancel := context.WithTimeout(ctx, MembershipWait)
|
|
||||||
defer cancel()
|
|
||||||
_, err := b.JS.Publish(broker.MembershipSubject(node, module), body, nats.Context(ctx))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("issuing %s on %s its membership: %w", module, node, err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b OverNATS) PublishDeclaration(ctx context.Context, node string, body []byte) error {
|
func (b OverNATS) PublishDeclaration(ctx context.Context, node string, body []byte) error {
|
||||||
_, err := b.JS.Publish(DeclareSubject(node), body, nats.Context(ctx))
|
_, err := b.JS.Publish(DeclareSubject(node), body, nats.Context(ctx))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ func ConnectNats(js *broker.JetStream, enroller Enroller, listener Listener) *Se
|
|||||||
inbound: Nats(js),
|
inbound: Nats(js),
|
||||||
bus: OverNATS{JS: js.Context(), Conn: js.Conn()},
|
bus: OverNATS{JS: js.Context(), Conn: js.Conn()},
|
||||||
js: js,
|
js: js,
|
||||||
consumers: js,
|
|
||||||
enroller: enroller,
|
enroller: enroller,
|
||||||
listener: listener,
|
listener: listener,
|
||||||
log: newLog(),
|
log: newLog(),
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
package link
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/broker"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ensured struct{ consumers []broker.Consumer }
|
|
||||||
|
|
||||||
func (e *ensured) EnsureConsumer(c broker.Consumer) error {
|
|
||||||
e.consumers = append(e.consumers, c)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type acceptsAs string
|
|
||||||
|
|
||||||
func (n acceptsAs) Enrol(context.Context, EnrolRequest) (EnrolReply, error) {
|
|
||||||
return EnrolReply{Accepted: true, Node: string(n)}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type anEnrolment struct{ body []byte }
|
|
||||||
|
|
||||||
func (m anEnrolment) Kind() string { return "enrol" }
|
|
||||||
func (m anEnrolment) Body() []byte { return m.body }
|
|
||||||
func (m anEnrolment) Redelivered() bool { return false }
|
|
||||||
func (m anEnrolment) HeldFor() time.Duration { return 0 }
|
|
||||||
func (m anEnrolment) Answer(context.Context, []byte) error { return nil }
|
|
||||||
func (m anEnrolment) Took() error { return nil }
|
|
||||||
func (m anEnrolment) Hold(time.Duration) error { return nil }
|
|
||||||
func (m anEnrolment) Drop() error { return nil }
|
|
||||||
|
|
||||||
// **A node that enrols can hear its declarations at once** (novox/hq 04-ISSUES/146): its consumer is
|
|
||||||
// made as it enrols, not only when the control plane next starts — the first machine of a mesh
|
|
||||||
// enrols after the control plane is up, and heard nothing.
|
|
||||||
func TestAnEnrolledNodeIsGivenHowItHearsItsDeclarations(t *testing.T) {
|
|
||||||
made := &ensured{}
|
|
||||||
s := &Server{enroller: acceptsAs("anchor"), consumers: made, log: log.New(io.Discard, "", 0)}
|
|
||||||
body, _ := json.Marshal(EnrolRequest{Node: "anchor"})
|
|
||||||
s.enrolling(context.Background(), anEnrolment{body: body})
|
|
||||||
|
|
||||||
want := broker.NodeConsumer("anchor")
|
|
||||||
if len(made.consumers) != 1 || made.consumers[0].Name != want.Name || made.consumers[0].Stream != want.Stream {
|
|
||||||
t.Fatalf("the enrolled node was given %v, want its own declaration consumer %v", made.consumers, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
package link_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/link"
|
|
||||||
)
|
|
||||||
|
|
||||||
// **A token issued for a tunnel key takes that key and no other** (novox/hq ADR 0169). The hub was
|
|
||||||
// sent the key before the token was shown, so another key is a machine it does not know.
|
|
||||||
func TestATokenIssuedForATunnelKeyTakesThatKeyAndNoOther(t *testing.T) {
|
|
||||||
inv, ident := aMeshReadyToEnrol(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
secret, public := aTokenFor(t, inv, "joiner")
|
|
||||||
node, err := inv.NodeByName(ctx, "joiner")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
const issuedFor = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
|
||||||
if err := inv.BindTokenToKey(ctx, node.ID, issuedFor); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
e := link.Enrolment{Inventory: inv, Identity: ident}
|
|
||||||
|
|
||||||
_, err = e.Enrol(ctx, link.EnrolRequest{Node: "joiner", Secret: secret, PublicKey: public,
|
|
||||||
OverlayKey: "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="})
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "issued for the tunnel key") {
|
|
||||||
t.Fatalf("a token issued for one key took another: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := e.Enrol(ctx, link.EnrolRequest{Node: "joiner", Secret: secret, PublicKey: public,
|
|
||||||
OverlayKey: issuedFor}); err != nil {
|
|
||||||
t.Fatalf("the key the token was issued for was refused: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -86,18 +86,6 @@ func (e Enrolment) Enrol(ctx context.Context, request EnrolRequest) (reply Enrol
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return EnrolReply{}, err
|
return EnrolReply{}, err
|
||||||
}
|
}
|
||||||
// **A token issued for a tunnel key takes that key and no other** (novox/hq ADR 0169). The hub
|
|
||||||
// was told it before the token was shown; another key is a machine the hub does not know.
|
|
||||||
// Checked before anything is recorded, so a refusal changes nothing.
|
|
||||||
bound, err := e.Inventory.TokenKey(ctx, secret)
|
|
||||||
if err != nil {
|
|
||||||
return EnrolReply{}, err
|
|
||||||
}
|
|
||||||
if bound != "" && request.OverlayKey != bound {
|
|
||||||
return EnrolReply{}, fmt.Errorf("%s's token was issued for the tunnel key %s and the machine "+
|
|
||||||
"offered %q — the key it made with `nox-mesh-host key` is the one to issue for",
|
|
||||||
node.Name, bound, request.OverlayKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := e.Identity.RecordNodeKey(ctx, node.ID, public); err != nil {
|
if _, err := e.Identity.RecordNodeKey(ctx, node.ID, public); err != nil {
|
||||||
return EnrolReply{}, fmt.Errorf("%s's key could not be recorded: %w", node.Name, err)
|
return EnrolReply{}, fmt.Errorf("%s's key could not be recorded: %w", node.Name, err)
|
||||||
@@ -298,39 +286,18 @@ func (e Enrolment) Heard(ctx context.Context, report Report) (news bool, err err
|
|||||||
// schedule, when what it holds changes, not only after an apply — and never cleared by a
|
// schedule, when what it holds changes, not only after an apply — and never cleared by a
|
||||||
// report that carries none, which is every bare word that the node is there. An adopted node
|
// report that carries none, which is every bare word that the node is there. An adopted node
|
||||||
// always names its firewall, so a report from one replaces all three, emptied held included.
|
// always names its firewall, so a report from one replaces all three, emptied held included.
|
||||||
if len(report.Held) > 0 || report.Firewall != "" || len(report.Reachable) > 0 || len(report.Strays) > 0 {
|
if len(report.Held) > 0 || report.Firewall != "" || len(report.Reachable) > 0 {
|
||||||
held := make([]inventory.Held, 0, len(report.Held))
|
held := make([]inventory.Held, 0, len(report.Held))
|
||||||
for _, h := range report.Held {
|
for _, h := range report.Held {
|
||||||
held = append(held, inventory.Held{ID: h.ID, Module: h.Module, Kind: h.Kind,
|
held = append(held, inventory.Held{ID: h.ID, Module: h.Module, Kind: h.Kind,
|
||||||
Target: h.Target, Since: h.Since, Changed: h.Changed, Kept: h.Kept, Facts: h.Facts})
|
Target: h.Target, Since: h.Since, Changed: h.Changed, Kept: h.Kept})
|
||||||
}
|
|
||||||
strays := make([]inventory.Stray, 0, len(report.Strays))
|
|
||||||
for _, s := range report.Strays {
|
|
||||||
strays = append(strays, inventory.Stray{Kind: s.Kind, Name: s.Name, Detail: s.Detail})
|
|
||||||
}
|
}
|
||||||
reachable := make([]inventory.Reach, 0, len(report.Reachable))
|
reachable := make([]inventory.Reach, 0, len(report.Reachable))
|
||||||
for _, r := range report.Reachable {
|
for _, r := range report.Reachable {
|
||||||
reachable = append(reachable, inventory.Reach{Protocol: r.Protocol, Address: r.Address,
|
reachable = append(reachable, inventory.Reach{Protocol: r.Protocol, Address: r.Address,
|
||||||
Port: r.Port, By: r.By, Published: r.Published, ContainerPort: r.ContainerPort})
|
Port: r.Port, By: r.By, Published: r.Published, ContainerPort: r.ContainerPort})
|
||||||
}
|
}
|
||||||
if err := e.Inventory.RecordAdoptionWithStrays(ctx, node.ID, held, report.Firewall, reachable, strays); err != nil {
|
if err := e.Inventory.RecordAdoption(ctx, node.ID, held, report.Firewall, reachable); err != nil {
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// What filters the machine, and the state of its found firewall (novox/hq ADR 0168), whenever
|
|
||||||
// it says — every apply of a host that knows how, adopted or converged; never cleared by a
|
|
||||||
// report that carries none, which is every bare word that the node is there.
|
|
||||||
if len(report.Filters) > 0 || report.FoundFirewall != nil {
|
|
||||||
filters := make([]inventory.Filter, 0, len(report.Filters))
|
|
||||||
for _, f := range report.Filters {
|
|
||||||
filters = append(filters, inventory.Filter{Where: f.Where, Owner: f.Owner, Refuses: f.Refuses})
|
|
||||||
}
|
|
||||||
var found *inventory.FoundFirewall
|
|
||||||
if report.FoundFirewall != nil {
|
|
||||||
found = &inventory.FoundFirewall{Kind: report.FoundFirewall.Kind, Active: report.FoundFirewall.Active,
|
|
||||||
RetiredBy: report.FoundFirewall.RetiredBy}
|
|
||||||
}
|
|
||||||
if err := e.Inventory.RecordFiltering(ctx, node.ID, filters, found); err != nil {
|
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -353,13 +320,6 @@ func (e Enrolment) Heard(ctx context.Context, report Report) (news bool, err err
|
|||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(report.Profile) > 0 {
|
|
||||||
// The latest wins, as at enrolment: a capability the machine lost is one the plan must
|
|
||||||
// stop counting on (novox/hq ADR 0161).
|
|
||||||
if err := e.Inventory.RecordProfile(ctx, node.ID, report.Profile); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// What it says about the tunnel it carried (novox/hq ADR 0105), whenever it says it.
|
// What it says about the tunnel it carried (novox/hq ADR 0105), whenever it says it.
|
||||||
if report.Tunnel != nil {
|
if report.Tunnel != nil {
|
||||||
if err := e.Inventory.RecordCarriedTunnel(ctx, node.ID, inventory.Carried{
|
if err := e.Inventory.RecordCarriedTunnel(ctx, node.ID, inventory.Carried{
|
||||||
|
|||||||
@@ -218,45 +218,3 @@ func TestWhatAnAdoptedNodeHoldsIsKeptAndAnAliveWordDoesNotWipeIt(t *testing.T) {
|
|||||||
t.Fatalf("a report from an adopted node holding nothing did not empty held: %+v", got)
|
t.Fatalf("a report from an adopted node holding nothing did not empty held: %+v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// What filters a machine, and the state of its found firewall, are kept from every report that
|
|
||||||
// carries them and never cleared by one that does not (novox/hq ADR 0168).
|
|
||||||
func TestWhatFiltersAMachineIsKeptFromItsReport(t *testing.T) {
|
|
||||||
inv, _, _ := heardFrom(t, link.Report{
|
|
||||||
Node: "home-server", Applied: []string{"a"},
|
|
||||||
Filters: []link.Filter{
|
|
||||||
{Where: "table inet mesh, chain forward", Owner: "mesh", Refuses: "policy drop"},
|
|
||||||
{Where: "chain HAL-MESH-ONLY (iptables-legacy)", Owner: "other", Refuses: "-j DROP"},
|
|
||||||
},
|
|
||||||
FoundFirewall: &link.FoundFirewall{Kind: "ufw", Active: false, RetiredBy: "found-inactive"},
|
|
||||||
})
|
|
||||||
ctx := context.Background()
|
|
||||||
f, err := inv.FilteringOf(ctx, "home-server")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(f.Filters) != 2 || f.Filters[1].Owner != inventory.FilterOther || f.Alone() {
|
|
||||||
t.Fatalf("recorded %+v", f)
|
|
||||||
}
|
|
||||||
if f.FoundFirewall == nil || f.FoundFirewall.RetiredBy != "found-inactive" || f.FoundFirewall.Active {
|
|
||||||
t.Fatalf("the found firewall's state: %+v", f.FoundFirewall)
|
|
||||||
}
|
|
||||||
if len(f.Others()) != 1 || f.Others()[0].Where != "chain HAL-MESH-ONLY (iptables-legacy)" {
|
|
||||||
t.Fatalf("others: %+v", f.Others())
|
|
||||||
}
|
|
||||||
// A bare word that the node is there clears nothing.
|
|
||||||
if _, err := (link.Enrolment{Inventory: inv}).Heard(ctx, link.Report{Node: "home-server"}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if again, _ := inv.FilteringOf(ctx, "home-server"); len(again.Filters) != 2 {
|
|
||||||
t.Fatalf("a bare report cleared what filters the machine: %+v", again)
|
|
||||||
}
|
|
||||||
// The next full report replaces it: the chain removed by hand is gone from the record.
|
|
||||||
if _, err := (link.Enrolment{Inventory: inv}).Heard(ctx, link.Report{Node: "home-server", Applied: []string{"a"},
|
|
||||||
Filters: []link.Filter{{Where: "table inet mesh, chain forward", Owner: "mesh", Refuses: "policy drop"}}}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if again, _ := inv.FilteringOf(ctx, "home-server"); len(again.Filters) != 1 || !again.Alone() {
|
|
||||||
t.Fatalf("the next report did not replace what filters the machine: %+v", again)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
package link_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/link"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A report may carry the machine's profile, detected again by the apply that reports, and the latest
|
|
||||||
// replaces what enrolment recorded (novox/hq ADR 0161): a machine that switched its network manager
|
|
||||||
// is a machine whose uplink holder lacks a capability at its next push, not at its next enrolment.
|
|
||||||
func TestAReportsProfileReplacesTheEnrolledOne(t *testing.T) {
|
|
||||||
e, _, _ := anEnrolledHub(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
first := map[string]any{"capabilities": []any{map[string]any{"name": "uplink-networkmanager", "present": true}}}
|
|
||||||
if _, err := e.Heard(ctx, link.Report{Node: "anchor", Profile: first}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
got, err := e.Inventory.Profile(ctx, "anchor")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(got) != 1 || got[0].Name != "uplink-networkmanager" || !got[0].Present {
|
|
||||||
t.Fatalf("the report's profile was not kept: %+v", got)
|
|
||||||
}
|
|
||||||
// The machine switched managers; the next report says so and the old fact is gone.
|
|
||||||
second := map[string]any{"capabilities": []any{map[string]any{"name": "uplink-systemd-networkd", "present": true}}}
|
|
||||||
if _, err := e.Heard(ctx, link.Report{Node: "anchor", Profile: second}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
got, err = e.Inventory.Profile(ctx, "anchor")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(got) != 1 || got[0].Name != "uplink-systemd-networkd" {
|
|
||||||
t.Fatalf("the latest profile did not replace the earlier one: %+v", got)
|
|
||||||
}
|
|
||||||
// A report with no profile leaves the last one standing.
|
|
||||||
if _, err := e.Heard(ctx, link.Report{Node: "anchor", Host: "1"}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got, _ = e.Inventory.Profile(ctx, "anchor"); len(got) != 1 {
|
|
||||||
t.Fatalf("a report without a profile erased it: %+v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -193,24 +193,6 @@ type Report struct {
|
|||||||
// refuses it whole — which is right, and makes every new field a flag day that the mesh could
|
// refuses it whole — which is right, and makes every new field a flag day that the mesh could
|
||||||
// not see coming.
|
// not see coming.
|
||||||
Host string `json:"host,omitempty"`
|
Host string `json:"host,omitempty"`
|
||||||
|
|
||||||
// Strays is what runs on the machine that the mesh neither wrote nor holds (ADR 0163).
|
|
||||||
Strays []Stray `json:"strays,omitempty"`
|
|
||||||
|
|
||||||
// Filters is what filters the machine now: every table and chain that refuses traffic, with
|
|
||||||
// its owner — the mesh's, the found firewall's, the container runtime's own, a ban, or other
|
|
||||||
// (novox/hq ADR 0168). Every machine reports it, adopted or converged; absent from a host older
|
|
||||||
// than this.
|
|
||||||
Filters []Filter `json:"filters,omitempty"`
|
|
||||||
// FoundFirewall is the state of the firewall a converged machine was found with: in force now
|
|
||||||
// or not, and how it came to be inactive — the mesh disabled it, or it was found so (ADR 0168).
|
|
||||||
FoundFirewall *FoundFirewall `json:"found_firewall,omitempty"`
|
|
||||||
|
|
||||||
// Profile is what the machine can do, detected again by this apply (novox/hq ADR 0161): the
|
|
||||||
// same shape enrolment sends, so a machine that gained or lost a capability — switched its
|
|
||||||
// network manager — is known at its next push and not at its next enrolment. Absent from a host
|
|
||||||
// older than this, and then the enrolment's profile stands.
|
|
||||||
Profile map[string]any `json:"profile,omitempty"`
|
|
||||||
// Reachable is what can be reached on the machine now: every listening socket and every
|
// Reachable is what can be reached on the machine now: every listening socket and every
|
||||||
// published container port. Only an adopted node reports it; it is what converging previews.
|
// published container port. Only an adopted node reports it; it is what converging previews.
|
||||||
Reachable []Reach `json:"reachable,omitempty"`
|
Reachable []Reach `json:"reachable,omitempty"`
|
||||||
@@ -282,31 +264,6 @@ type Held struct {
|
|||||||
Changed string `json:"changed,omitempty"`
|
Changed string `json:"changed,omitempty"`
|
||||||
// Kept is where a file's original was kept.
|
// Kept is where a file's original was kept.
|
||||||
Kept string `json:"kept,omitempty"`
|
Kept string `json:"kept,omitempty"`
|
||||||
// Facts is the found thing beside what the module declares — what a take compares (novox/hq
|
|
||||||
// ADR 0163): the host's own shape, carried as data and read by the preview.
|
|
||||||
Facts map[string]any `json:"facts,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// A Filter is one place on a machine that refuses traffic, with its owner (novox/hq ADR 0168):
|
|
||||||
// the host's own shape, carried as data.
|
|
||||||
type Filter struct {
|
|
||||||
Where string `json:"where"`
|
|
||||||
Owner string `json:"owner"`
|
|
||||||
Refuses string `json:"refuses"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// FoundFirewall is the state of a converged machine's found firewall (ADR 0168).
|
|
||||||
type FoundFirewall struct {
|
|
||||||
Kind string `json:"kind"`
|
|
||||||
Active bool `json:"active"`
|
|
||||||
RetiredBy string `json:"retired_by,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// A Stray is a container a machine runs that the mesh neither wrote nor holds (ADR 0163).
|
|
||||||
type Stray struct {
|
|
||||||
Kind string `json:"kind"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Detail string `json:"detail,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reach is one thing reachable on the machine: a listening socket, or a published container port.
|
// Reach is one thing reachable on the machine: a listening socket, or a published container port.
|
||||||
|
|||||||
@@ -73,8 +73,6 @@ type Server struct {
|
|||||||
inbound Inbound
|
inbound Inbound
|
||||||
bus Bus
|
bus Bus
|
||||||
js *broker.JetStream
|
js *broker.JetStream
|
||||||
// consumers makes a node's declaration consumer as it enrols; the bus connection, or a stand-in.
|
|
||||||
consumers interface{ EnsureConsumer(broker.Consumer) error }
|
|
||||||
|
|
||||||
enroller Enroller
|
enroller Enroller
|
||||||
listener Listener
|
listener Listener
|
||||||
@@ -385,7 +383,6 @@ func (s *Server) enrolling(ctx context.Context, m Control) {
|
|||||||
default:
|
default:
|
||||||
reply = accepted
|
reply = accepted
|
||||||
s.log.Printf("enrolled %s", accepted.Node)
|
s.log.Printf("enrolled %s", accepted.Node)
|
||||||
s.hearsItsDeclarations(accepted.Node)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,24 +402,6 @@ func (s *Server) enrolling(ctx context.Context, m Control) {
|
|||||||
_ = m.Took()
|
_ = m.Took()
|
||||||
}
|
}
|
||||||
|
|
||||||
// hearsItsDeclarations makes the consumer a node reads its declarations through, as it enrols and
|
|
||||||
// before it is answered.
|
|
||||||
//
|
|
||||||
// **Created at enrolment, as the consumer's own doc has always said** (novox/hq 04-ISSUES/146). It
|
|
||||||
// was asserted only when the control plane started, so the first machine of a mesh — which enrols
|
|
||||||
// after the control plane is already up — joined and then heard nothing, its host retrying "consumer
|
|
||||||
// not found" for ever. Failing here is said and does not unspend the token: the next start of the
|
|
||||||
// control plane asserts it again.
|
|
||||||
func (s *Server) hearsItsDeclarations(node string) {
|
|
||||||
if s.consumers == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := s.consumers.EnsureConsumer(broker.NodeConsumer(node)); err != nil {
|
|
||||||
s.log.Printf("%s enrolled, and how it hears its declarations could not be made — it will hear "+
|
|
||||||
"nothing until the control plane next starts: %v", node, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// wasBuilt keeps what a builder said, whichever way it went.
|
// wasBuilt keeps what a builder said, whichever way it went.
|
||||||
//
|
//
|
||||||
// This is for results nobody was waiting for. A build asked for with `build` is answered directly
|
// This is for results nobody was waiting for. A build asked for with `build` is answered directly
|
||||||
|
|||||||
@@ -55,26 +55,6 @@ type Token struct {
|
|||||||
// that it speaks the firewall found on the machine, because an adopted node keeps that firewall
|
// that it speaks the firewall found on the machine, because an adopted node keeps that firewall
|
||||||
// in force. Absent for a converged node, so a converged token is byte for byte what it was.
|
// in force. Absent for a converged node, so a converged token is byte for byte what it was.
|
||||||
Adopted bool `json:"adopted,omitempty"`
|
Adopted bool `json:"adopted,omitempty"`
|
||||||
|
|
||||||
// Tunnel is the one peer a joining machine needs, when the token was issued for its tunnel key
|
|
||||||
// (novox/hq ADR 0169). The machine brings its tunnel up from this alone and reaches the bus over
|
|
||||||
// it, at an address on the private network — so the bus is never open to the internet. Absent
|
|
||||||
// on a token issued without a key, which then reads byte for byte as before.
|
|
||||||
Tunnel *Tunnel `json:"tunnel,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tunnel is the joining machine's side of its first tunnel: its own address and the hub to reach.
|
|
||||||
type Tunnel struct {
|
|
||||||
// Key is the public half of the key the machine made itself, which this token was issued for.
|
|
||||||
// The private half never left the machine (novox/hq ADR 0004).
|
|
||||||
Key string `json:"key"`
|
|
||||||
// Address is the machine's own address on the private network, with its prefix.
|
|
||||||
Address string `json:"address"`
|
|
||||||
// Range is the private network, routed through the hub until the machine is told more.
|
|
||||||
Range string `json:"range"`
|
|
||||||
// HubKey and HubEndpoint are the hub's tunnel key and where it is dialled.
|
|
||||||
HubKey string `json:"hub_key"`
|
|
||||||
HubEndpoint string `json:"hub_endpoint"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Missing names the parts that are not filled in.
|
// Missing names the parts that are not filled in.
|
||||||
@@ -103,19 +83,6 @@ func (t Token) Missing() []string {
|
|||||||
if strings.TrimSpace(t.Secret) == "" {
|
if strings.TrimSpace(t.Secret) == "" {
|
||||||
missing = append(missing, "the one-time secret — nothing to present")
|
missing = append(missing, "the one-time secret — nothing to present")
|
||||||
}
|
}
|
||||||
if t.Tunnel != nil {
|
|
||||||
for _, part := range []struct{ value, says string }{
|
|
||||||
{t.Tunnel.Key, "the machine's own tunnel key — the hub would not know it"},
|
|
||||||
{t.Tunnel.Address, "the machine's address on the private network"},
|
|
||||||
{t.Tunnel.Range, "the private network's range — nothing to route through the hub"},
|
|
||||||
{t.Tunnel.HubKey, "the hub's tunnel key — nothing to dial"},
|
|
||||||
{t.Tunnel.HubEndpoint, "where the hub's tunnel is dialled"},
|
|
||||||
} {
|
|
||||||
if strings.TrimSpace(part.value) == "" {
|
|
||||||
missing = append(missing, part.says)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return missing
|
return missing
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -172,38 +172,3 @@ func TestATokenWithNoNameIsRefused(t *testing.T) {
|
|||||||
t.Fatalf("the refusal does not say what is missing: %v", without.Missing())
|
t.Fatalf("the refusal does not say what is missing: %v", without.Missing())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A token issued for a tunnel key carries the one peer a joining machine needs (novox/hq ADR 0169),
|
|
||||||
// and says which part is missing rather than producing a tunnel that never answers.
|
|
||||||
func TestATokenThroughTheTunnelCarriesThePeerOrSaysWhatIsMissing(t *testing.T) {
|
|
||||||
whole := Token{Node: "n", Broker: "10.42.0.1:4222", Fingerprint: "sha256:x", Signer: make([]byte, 32), Secret: "s",
|
|
||||||
Tunnel: &Tunnel{Key: "k", Address: "10.42.0.9/32", Range: "10.42.0.0/16", HubKey: "h", HubEndpoint: "198.51.100.1:51820"}}
|
|
||||||
if !whole.Complete() {
|
|
||||||
t.Fatalf("a whole token through the tunnel reads as missing %v", whole.Missing())
|
|
||||||
}
|
|
||||||
encoded, err := whole.Encode()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
back, err := Decode(encoded)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if back.Tunnel == nil || *back.Tunnel != *whole.Tunnel {
|
|
||||||
t.Fatalf("the tunnel did not survive the round trip: %+v", back.Tunnel)
|
|
||||||
}
|
|
||||||
|
|
||||||
part := whole
|
|
||||||
part.Tunnel = &Tunnel{Key: "k", Address: "10.42.0.9/32"}
|
|
||||||
if len(part.Missing()) != 3 {
|
|
||||||
t.Errorf("a tunnel without the hub and the range should name three missing parts: %v", part.Missing())
|
|
||||||
}
|
|
||||||
|
|
||||||
// And a token issued without a key carries no tunnel at all, byte for byte as before.
|
|
||||||
plain := whole
|
|
||||||
plain.Tunnel = nil
|
|
||||||
raw, _ := plain.Encode()
|
|
||||||
if strings.Contains(raw, "tunnel") {
|
|
||||||
t.Error("a token without a key mentions a tunnel")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user