diff --git a/internal/catalogue/licence_test.go b/internal/catalogue/licence_test.go index 3cc9581..0860b62 100644 --- a/internal/catalogue/licence_test.go +++ b/internal/catalogue/licence_test.go @@ -185,3 +185,85 @@ func TestAnUnanswerableRequirementDoesNotRemoveAMachineFromTheMesh(t *testing.T) t.Fatal("the requirement nothing answers was accepted when it was actually asked") } } + +// A same-node provider that mints no credential but serves a port the consumer cannot guess must +// still deliver that port. A `local-model` provider (ollama) provides `model-access` at node scope +// and serves a port and a model name, minting nothing; a co-located consumer requires and binds it +// and has a file that names `${bound:model-access:port}`. The served facts never reached the +// consumer's needs — node scope set `local`, not `brokered`, so the delivering branch was skipped — +// and the consumer's file was refused for naming a provision it "did not require". The endpoint is +// keyless, but a port is still a fact nobody can invent. +func TestAKeylessSameNodeProviderStillDeliversWhatItServes(t *testing.T) { + provider := Manifest{Module: "local-model", + Provides: []Offer{{Name: "model-access", Scope: ScopeNode}}, + Serves: map[string]map[string]any{ + "model-access": {"port": 11434, "model": "a-model"}}} + consumer := Manifest{Module: "assistant", Requires: []string{"model-access"}, + Binds: map[string]string{"model-access": "/etc/assistant/model.json"}} + + // node.At empty: a single-node deployment with no private network. The delivered binding must + // still carry a usable address — loopback, because a machine off the network still reaches + // itself, and an empty `at` would be written into the consumer's file as a host that resolves + // to nothing. + got, err := Resolve( + map[string]Manifest{"local-model": provider, "assistant": consumer}, + []string{"assistant", "local-model"}, + Node{Name: "workstation"}, + World{}) + if err != nil { + t.Fatalf("a keyless same-node model endpoint was refused: %v", err) + } + + var found *Needed + for i := range got.Needs { + if got.Needs[i].Name == "model-access" && got.Needs[i].For == "assistant" { + found = &got.Needs[i] + } + } + if found == nil { + t.Fatalf("the served endpoint was not delivered to the consumer at all: %+v", got.Needs) + } + if found.ByRecord { + t.Fatal("a same-node endpoint was treated as a record, so the reachability rule would apply") + } + if found.At == "" { + t.Fatalf("the binding carries no address, so its file names a host that resolves to nothing: %+v", found) + } + if found.At != "127.0.0.1" { + t.Fatalf("off the private network the address must fall back to loopback, got %q", found.At) + } + if got, want := plainly(found.Serves["port"]), "11434"; got != want { + t.Fatalf("the port the consumer cannot guess was not delivered: got %q want %q", got, found.Serves) + } + if found.Serves["model"] != "a-model" { + t.Fatalf("the extra served fact was not delivered: %+v", found.Serves) + } + + // End to end: a file that names ${bound:model-access:...} is filled rather than refused, which + // is the whole failure this fixes — boundInto reads knownFor, and knownFor reads the needs. + consumer.Resources = []map[string]any{{ + "id": "env", "type": "file", "path": "/etc/assistant/.env", + "content": "OPENAI_BASE_URL=http://${bound:model-access:at}:${bound:model-access:port}/v1\n", + }} + got, err = Resolve( + map[string]Manifest{"local-model": provider, "assistant": consumer}, + []string{"assistant", "local-model"}, + Node{Name: "workstation"}, + World{}) + if err != nil { + t.Fatalf("resolution refused the consumer with a bound file: %v", err) + } + out, err := got.Declaration(Rendering{}) + if err != nil { + t.Fatalf("the declaration refused the consumer's bound file: %v", err) + } + var env string + for _, res := range out { + if res["path"] == "/etc/assistant/.env" { + env, _ = res["content"].(string) + } + } + if want := "http://127.0.0.1:11434/v1"; !strings.Contains(env, want) { + t.Fatalf("the bound file was not filled with the served endpoint, want %q:\n%s", want, env) + } +} diff --git a/internal/catalogue/resolve.go b/internal/catalogue/resolve.go index d046a9a..ef0ab9d 100644 --- a/internal/catalogue/resolve.go +++ b/internal/catalogue/resolve.go @@ -251,6 +251,26 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world needs = append(needs, Needed{ Name: want, From: node.Name, At: node.At, Serves: servedHere(catalogue, chosen, want), For: because[want]}) + } else if served := servedHere(catalogue, chosen, want); len(served) > 0 { + // 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. + // **The reachability rule does not apply**: both ends are on this same machine, so + // there is no private network to share and nothing to refuse (novox/hq ADR 0024's + // sibling case — a same-node keyless endpoint that the consumer still cannot invent + // the port for, exactly what declaration.go's here() was left to patch after the + // fact). Delivering it here as a need is what lets knownFor see it, so a file that + // says ${bound::port} is filled rather than refused. + // + // The address is this machine's own name on the private network when it has one, and + // loopback when it does not — a machine off the network still reaches itself, and the + // consumer's binding must carry a usable `at`. The same fallback declaration.go's + // here() applies, done here because this need is now found before here() would run. + at := node.At + if at == "" { + at = "127.0.0.1" + } + needs = append(needs, Needed{ + Name: want, From: node.Name, At: at, Serves: served, For: because[want]}) } continue }