diff --git a/cmd/mesh-controller/merge_gate_handing_test.go b/cmd/mesh-controller/merge_gate_handing_test.go index 242aa76f..a5840284 100644 --- a/cmd/mesh-controller/merge_gate_handing_test.go +++ b/cmd/mesh-controller/merge_gate_handing_test.go @@ -1,10 +1,13 @@ package main import ( + "os" "testing" "time" + "github.com/novox/mesh-controller/internal/catalogue" snapshot "github.com/novox/mesh-controller/internal/facts" + "github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/link" ) @@ -26,3 +29,57 @@ func TestIssue969TheGateGivesAMachineItsContractSoItTakesHandedFiles(t *testing. t.Fatalf("a snapshot that carries no contract states nothing: %+v, %v", none, ok) } } + +// Issue 969, through the store: a machine whose engine stated the handed contract is in the snapshot with it, the +// gate raises it as taking handed files, and one whose statement is below it is raised as not taking them. Run in +// the merge check with the other tests that need a store; the composition itself, handed here and refused there, is +// the catalogue's TestAHandedModuleNamingARunTimeBoundKeyComposesOnlyWhereItIsHanded. +func TestIssue969TheGateRaisesEachMachineAsItsEngineStatedItself(t *testing.T) { + open := aMesh(t) + ctx := t.Context() + said := time.Date(2026, 10, 16, 12, 0, 0, 0, time.UTC) + for node, contract := range map[string]int{"anchor": link.HandedContract, "laptop": link.HandedContract - 1} { + if _, err := open.inventory.RecordHealth(ctx, inventory.NodeHealth{Node: node, Contract: contract, + SaidAt: said, HeardAt: said}); err != nil { + t.Fatal(err) + } + } + f, err := gatherFacts(ctx, open, "2.11.17") + if err != nil { + t.Fatal(err) + } + if len(f.Machines) != 2 { + t.Fatalf("the snapshot holds %d machines", len(f.Machines)) + } + want := map[string]bool{} + for _, m := range f.Machines { + if m.Contract == 0 { + t.Fatalf("%s: the snapshot carries no contract", m.Name) + } + want[m.Name] = m.Contract >= link.HandedContract + } + handing := 0 + for _, hands := range want { + if hands { + handing++ + } + } + if handing != 1 { + t.Fatalf("one machine takes handed files in the snapshot, got %v", want) + } + + raised, drop, err := throwawayStores(ctx, os.Getenv("MESH_TEST_POSTGRES")) + if err != nil { + t.Fatal(err) + } + defer drop() + if _, err := raiseFromFacts(ctx, raised, f, map[string]catalogue.Manifest{}, map[string]inventory.Source{}); err != nil { + t.Fatal(err) + } + for name, hands := range want { + got, err := engineTakesHanded(ctx, raised.inventory, name) + if err != nil || got != hands { + t.Errorf("%s: the gate raises it as taking handed files = %v (%v), the mesh says %v", name, got, err, hands) + } + } +} diff --git a/internal/catalogue/handed_bound_test.go b/internal/catalogue/handed_bound_test.go index 6b7349ba..526f4d29 100644 --- a/internal/catalogue/handed_bound_test.go +++ b/internal/catalogue/handed_bound_test.go @@ -123,3 +123,37 @@ func TestAHandedModulesLocalCredentialsOfTheVaultAreNamedByLocalInItsPlan(t *tes t.Fatalf("the plan names %v, want admin and internal-token", names) } } + +// **Issue 969: a key its provider serves only at run time.** A moved module's file may name `${bound:…}` of a +// provision whose provider tells it at run time (ssh-client's known_host of the forge). Where the machine takes +// handed files the client fills it and the module composes; where it does not the controller must fill it from what +// the provider serves statically, cannot, and refuses it with the very words an unmarked module is refused with +// (ADR 0349 §17: the old way, byte for byte). +func TestAHandedModuleNamingARunTimeBoundKeyComposesOnlyWhereItIsHanded(t *testing.T) { + text := "Host ${bound:postgres-database:at}\n KnownHost ${bound:postgres-database:known_host}\n" + moved := strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"}`, + `"requires":["postgres-database"],"own-secrets":{"token":"${dir:state}/token"}`, 1) + unmoved := strings.Replace(moved, `"moved":true,`, ``, 1) + if unmoved == moved { + t.Fatal("the twin is still marked moved") + } + + c, got := composeWith(t, withDatabase(t, moved, text, true), handingTo("store:5000")) + if strings.Contains(got, "known_host") || len(c.Filled) == 0 { + t.Fatalf("handed, the module is filled by the client and the controller names nothing: %v %s", c.Filled, got) + } + + for name, with := range map[string]Rendering{ + "an engine that does not take a filled file alone": {Provisioner: "mesh-provisioner-client", ArtifactStore: "store:5000"}, + "no provisioner client on the machine": {Hands: true, ArtifactStore: "store:5000"}, + } { + _, err := withDatabase(t, moved, text, true).Compose(sealedForBot(with)) + if err == nil || !strings.Contains(err.Error(), `asks its postgres-database binding for "known_host"`) { + t.Fatalf("%s: the key is not refused: %v", name, err) + } + _, before := withDatabase(t, unmoved, text, false).Compose(sealedForBot(with)) + if before == nil || before.Error() != err.Error() { + t.Fatalf("%s: refused otherwise than the unmarked module: %v / %v", name, err, before) + } + } +}