diff --git a/README.md b/README.md index 2e88cde..d7f6052 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ All kinds are namespaced, in `aisix.api7.ai/v1alpha1`. A `spec` is the AISIX res | `Model` | `aimodel` | `provider_key_ref: {name}`: a ProviderKey's `metadata.name` (direct models only) | | `APIKey` | `aikey` | `key_secret_ref: {name, key}`: the caller's plaintext key; only its SHA-256 is written | -Secret refs may name any Secret in the namespace except the controller's own `aisix-resources`. A `provider_key_ref` must name a ProviderKey that is served; one that lost a shared `display_name` to an older ProviderKey is rejected. +Secret refs may name any Secret in the namespace except the controller's own `aisix-resources`. A `provider_key_ref` must name a ProviderKey that is served, and resolves to the `display_name` that ProviderKey is actually served under. A resource already served under a `display_name` keeps it, so another resource claiming it is refused; when none is served under it, the oldest wins. ```yaml apiVersion: aisix.api7.ai/v1alpha1 diff --git a/internal/render/assemble.go b/internal/render/assemble.go index 8634e78..c65e99e 100644 --- a/internal/render/assemble.go +++ b/internal/render/assemble.go @@ -16,6 +16,9 @@ import ( // FormatVersion is the resources-file format the controller writes. const FormatVersion = "1" +// providerKeyField is how a Model entry names its ProviderKey's display_name. +const providerKeyField = "provider_key" + // Disposition is what happened to one custom resource. type Disposition struct { State State @@ -99,12 +102,14 @@ type servedKey struct { type Served struct { entries map[servedKey]Entry owners map[Key]string + // providers maps a served ProviderKey display_name to its metadata.name. + providers map[string]string } // ServedEntries parses a served document and its index. Anything unreadable // counts as nothing served. func ServedEntries(document, index []byte) Served { - out := Served{entries: map[servedKey]Entry{}, owners: map[Key]string{}} + out := Served{entries: map[servedKey]Entry{}, owners: map[Key]string{}, providers: map[string]string{}} if len(document) == 0 || len(index) == 0 { return out } @@ -126,7 +131,7 @@ func ServedEntries(document, index []byte) Served { continue } if id, ok := e[v1alpha1.IdentityField].(string); ok { - out.entries[servedKey{k, strings.ReplaceAll(id, "$$", "$")}] = e + out.entries[servedKey{k, unescapeDollars(id)}] = e } } } @@ -136,10 +141,15 @@ func ServedEntries(document, index []byte) Served { continue } out.owners[Key{v1alpha1.Kind(kind), name}] = id + if v1alpha1.Kind(kind) == v1alpha1.KindProviderKey { + out.providers[id] = name + } } return out } +func unescapeDollars(s string) string { return strings.ReplaceAll(s, "$$", "$") } + // get returns the entry a resource is served with, and its identity. func (s Served) get(src Source) (Entry, string, bool) { id, ok := s.owners[src.Key()] @@ -177,7 +187,131 @@ type item struct { entry Entry // identity the entry is served under. identity string - reason string + // providerRef is the metadata.name of the ProviderKey the entry uses. + providerRef string + reason string +} + +// assembly is the working state of one Assemble call. +type assembly struct { + items []*item + served Served + // providers finds ProviderKey items by metadata.name. + providers map[string]*item +} + +// fallback replaces an item with its served entry, or rejects it when it +// has none. +func (a *assembly) fallback(it *item, reason string) { + it.reason = reason + e, id, ok := a.served.get(it.source) + if !ok { + it.state, it.entry, it.identity, it.providerRef = Rejected, nil, "", "" + return + } + it.state, it.entry, it.identity, it.providerRef = Retained, e, id, "" + if it.source.Kind != v1alpha1.KindModel { + return + } + name, ok := e[providerKeyField].(string) + if !ok { + return + } + owner, ok := a.served.providers[unescapeDollars(name)] + if !ok { + a.drop(it, fmt.Sprintf("no ProviderKey owns %s %q", providerKeyField, unescapeDollars(name))) + return + } + it.providerRef = owner +} + +// drop rejects a Retained item. +func (a *assembly) drop(it *item, reason string) { + it.state, it.entry, it.identity, it.providerRef = Rejected, nil, "", "" + it.reason += "; the previously served version is no longer valid either: " + reason +} + +// holds reports whether an item is already served under its identity. +func (a *assembly) holds(it *item) bool { + _, id, ok := a.served.get(it.source) + return ok && id == it.identity +} + +// claim gives each identity to one item: the one served under it, else the +// oldest, which comes first in file order. +func (a *assembly) claim() bool { + winners := map[servedKey]*item{} + for _, it := range a.items { + if it.state == Rejected { + continue + } + k := servedKey{it.source.Kind, it.identity} + if w, ok := winners[k]; !ok || (a.holds(it) && !a.holds(w)) { + winners[k] = it + } + } + changed := false + for _, it := range a.items { + if it.state != Applied { + continue + } + if w := winners[servedKey{it.source.Kind, it.identity}]; w != it { + a.fallback(it, fmt.Sprintf("%s %q is already used by %s/%s", v1alpha1.IdentityField, it.identity, w.source.Kind, w.source.Name)) + changed = true + } + } + return changed +} + +// resolve points each Model at the display_name its ProviderKey is served +// under now. +func (a *assembly) resolve() bool { + changed := false + for _, it := range a.items { + if it.state == Rejected || it.providerRef == "" { + continue + } + var problem string + p := a.providers[it.providerRef] + switch { + case p == nil: + problem = fmt.Sprintf("%s names ProviderKey %q, which does not exist", v1alpha1.ProviderKeyRef, it.providerRef) + case p.state == Rejected: + problem = fmt.Sprintf("%s names ProviderKey %q, which is not served: %s", v1alpha1.ProviderKeyRef, it.providerRef, p.reason) + default: + it.setProviderKey(p.identity) + continue + } + if it.state == Applied { + a.fallback(it, problem) + } else { + a.drop(it, problem) + } + changed = true + } + return changed +} + +// setProviderKey copies the entry, since a Retained one is shared with Served. +func (it *item) setProviderKey(identity string) { + e := make(Entry, len(it.entry)+1) + for k, v := range it.entry { + e[k] = v + } + e[providerKeyField] = EscapeDollars(identity) + it.entry = e +} + +// settle runs until no state changes: a fallback can bring back an identity +// another item claims. +func (a *assembly) settle() { + for { + claimed := a.claim() + resolved := a.resolve() + if !claimed && !resolved { + return + } + } } // Assemble builds the document to serve. An entry the gateway rejects is @@ -185,23 +319,21 @@ type item struct { // the others down. With no served version it is left out. func Assemble(ctx context.Context, rendered []Rendered, served Served, v validate.Validator) (Outcome, error) { items := make([]*item, len(rendered)) - fallback := func(it *item, reason string) { - if e, id, ok := served.get(it.source); ok { - it.state, it.entry, it.identity, it.reason = Retained, e, id, reason - } else { - it.state, it.entry, it.identity, it.reason = Rejected, nil, "", reason - } - } + a := &assembly{items: items, served: served, providers: map[string]*item{}} for i, r := range rendered { - it := &item{source: r.Source, state: Applied, entry: r.Entry, identity: r.Source.Identity()} + it := &item{source: r.Source, state: Applied, entry: r.Entry, identity: r.Source.Identity(), providerRef: r.ProviderRef} if r.Err != "" { - fallback(it, r.Err) + a.fallback(it, r.Err) } items[i] = it + if r.Source.Kind == v1alpha1.KindProviderKey { + a.providers[r.Source.Name] = it + } } // Each failing round drops or replaces at least one entry. for round := 0; round < 2*len(items)+1; round++ { + a.settle() entries := map[v1alpha1.Kind][]Entry{} positions := map[servedKey]*item{} for _, it := range items { @@ -242,10 +374,9 @@ func Assemble(ctx context.Context, rendered []Rendered, served Served, v validat reason := strings.Join(faults[it], "; ") switch it.state { case Applied: - fallback(it, reason) + a.fallback(it, reason) case Retained: - it.state, it.entry, it.identity = Rejected, nil, "" - it.reason = it.reason + "; the previously served version is no longer valid either: " + reason + a.drop(it, reason) } } } diff --git a/internal/render/assemble_test.go b/internal/render/assemble_test.go index 7a50ccd..ca59a24 100644 --- a/internal/render/assemble_test.go +++ b/internal/render/assemble_test.go @@ -8,6 +8,7 @@ import ( "os" "strings" "testing" + "time" "github.com/api7/aisix-controller/api/v1alpha1" "github.com/api7/aisix-controller/internal/validate" @@ -312,3 +313,273 @@ func TestServedEntriesParse(t *testing.T) { } } } + +const ( + credOne = "sk-$$abc" + credTwo = "caller-key" +) + +// namedProvider is a ProviderKey; secretKey picks credOne ("openai"), +// credTwo ("caller"), or a missing key. +func namedProvider(name, display, secretKey string, age time.Duration) Source { + s := src(v1alpha1.KindProviderKey, name, map[string]any{ + "provider": "openai", "adapter": "openai", "api_base": "http://" + name + ":8080/v1", + v1alpha1.IdentityField: display, v1alpha1.APIKeySecretRef: ref("creds", secretKey), + }) + s.Created = t0.Add(age) + return s +} + +func invalid(s Source) Source { + s.Fields["no_such_field"] = 1 + return s +} + +func entries(t *testing.T, o Outcome, collection string) map[string]map[string]any { + t.Helper() + if o.Error != "" || o.Document == nil { + t.Fatalf("outcome = %+v", o) + } + var root map[string]any + if err := json.Unmarshal(o.Document, &root); err != nil { + t.Fatal(err) + } + out := map[string]map[string]any{} + for _, e := range list(root, collection) { + out[e[v1alpha1.IdentityField].(string)] = e + } + return out +} + +// wantModel checks which provider key a Model is served on, and that the +// provider key carries the expected credential. +func wantModel(t *testing.T, o Outcome, model, providerKey, credential string) { + t.Helper() + m, ok := entries(t, o, "models")[model] + if !ok { + t.Fatalf("model %s is not served:\n%s", model, o.Document) + } + if m["provider_key"] != providerKey { + t.Errorf("model %s: provider_key = %v, want %s", model, m["provider_key"], providerKey) + } + p, ok := entries(t, o, "provider_keys")[providerKey] + if !ok { + t.Fatalf("provider key %s is not served:\n%s", providerKey, o.Document) + } + if p["api_key"] != credential { + t.Errorf("provider key %s: api_key = %v, want %s", providerKey, p["api_key"], credential) + } +} + +func wantState(t *testing.T, o Outcome, kind v1alpha1.Kind, name string, state State, reason string) { + t.Helper() + d := disposition(o, kind, name) + if d.State != state || !strings.Contains(d.Reason, reason) { + t.Errorf("%s/%s: %+v, want state %d with reason %q", kind, name, d, state, reason) + } +} + +func TestFailedRenameKeepsModelsOnTheServedName(t *testing.T) { + for label, edited := range map[string]Source{ + "at render": namedProvider("p", "B", "typo", 0), + "by the gateway": invalid(namedProvider("p", "B", "openai", 0)), + } { + t.Run(label, func(t *testing.T) { + v := gatewayValidator(t, &fakeValidator{reject: rejectUnknownField}) + first := assemble(t, v, []Source{namedProvider("p", "A", "openai", 0), model("m", "p")}, nil) + out := assemble(t, v, []Source{edited, model("m", "p"), model("m2", "p")}, &first) + wantState(t, out, v1alpha1.KindProviderKey, "p", Retained, "") + wantState(t, out, v1alpha1.KindModel, "m", Applied, "") + wantState(t, out, v1alpha1.KindModel, "m2", Applied, "") + wantModel(t, out, "m", "A", credOne) + wantModel(t, out, "m2", "A", credOne) + }) + } +} + +func TestRenameOntoAServedNameFails(t *testing.T) { + v := gatewayValidator(t, &fakeValidator{}) + second := namedProvider("p2", "B", "caller", time.Hour) + first := assemble(t, v, []Source{namedProvider("p1", "A", "openai", 0), second, model("m1", "p1"), model("m2", "p2")}, nil) + out := assemble(t, v, []Source{namedProvider("p1", "B", "openai", 0), second, model("m1", "p1"), model("m2", "p2")}, &first) + wantState(t, out, v1alpha1.KindProviderKey, "p1", Retained, "already used by ProviderKey/p2") + wantState(t, out, v1alpha1.KindProviderKey, "p2", Applied, "") + wantState(t, out, v1alpha1.KindModel, "m1", Applied, "") + wantState(t, out, v1alpha1.KindModel, "m2", Applied, "") + wantModel(t, out, "m1", "A", credOne) + wantModel(t, out, "m2", "B", credTwo) + if !bytes.Equal(out.Document, first.Document) { + t.Errorf("served document changed:\n%s", out.Document) + } +} + +func TestRenameOntoARetainedNameFails(t *testing.T) { + failures := map[string]func(Source) Source{ + "at render": func(s Source) Source { + s.Fields[v1alpha1.APIKeySecretRef] = ref("creds", "typo") + return s + }, + "by the gateway": invalid, + } + ages := map[string][2]time.Duration{ + "holder older": {time.Hour, 0}, + "claimant older": {0, time.Hour}, + } + for failure, fail := range failures { + for age, a := range ages { + t.Run(failure+"/"+age, func(t *testing.T) { + v := gatewayValidator(t, &fakeValidator{reject: rejectUnknownField}) + first := assemble(t, v, []Source{ + namedProvider("p1", "A", "openai", a[0]), namedProvider("p2", "B", "caller", a[1]), model("m1", "p1"), + }, nil) + out := assemble(t, v, []Source{ + namedProvider("p1", "B", "openai", a[0]), fail(namedProvider("p2", "C", "caller", a[1])), model("m1", "p1"), + }, &first) + wantState(t, out, v1alpha1.KindProviderKey, "p2", Retained, "") + wantState(t, out, v1alpha1.KindProviderKey, "p1", Retained, "already used by ProviderKey/p2") + wantState(t, out, v1alpha1.KindModel, "m1", Applied, "") + wantModel(t, out, "m1", "A", credOne) + if _, ok := entries(t, out, "provider_keys")["B"]; !ok { + t.Errorf("p2's entry is gone:\n%s", out.Document) + } + }) + } + } +} + +func TestSwappedNamesBothApply(t *testing.T) { + v := gatewayValidator(t, &fakeValidator{}) + first := assemble(t, v, []Source{ + namedProvider("p1", "A", "openai", 0), namedProvider("p2", "B", "caller", time.Hour), model("m1", "p1"), model("m2", "p2"), + }, nil) + out := assemble(t, v, []Source{ + namedProvider("p1", "B", "openai", 0), namedProvider("p2", "A", "caller", time.Hour), model("m1", "p1"), model("m2", "p2"), + }, &first) + wantState(t, out, v1alpha1.KindProviderKey, "p1", Applied, "") + wantState(t, out, v1alpha1.KindProviderKey, "p2", Applied, "") + wantModel(t, out, "m1", "B", credOne) + wantModel(t, out, "m2", "A", credTwo) +} + +func TestFallingBackReclaimsTheOldName(t *testing.T) { + v := gatewayValidator(t, &fakeValidator{}) + holder := namedProvider("p2", "B", "caller", time.Hour) + first := assemble(t, v, []Source{namedProvider("p1", "A", "openai", 0), holder, namedProvider("p3", "X", "caller", 2*time.Hour)}, nil) + out := assemble(t, v, []Source{namedProvider("p1", "B", "openai", 0), holder, namedProvider("p3", "A", "caller", 2*time.Hour)}, &first) + wantState(t, out, v1alpha1.KindProviderKey, "p1", Retained, "already used by ProviderKey/p2") + wantState(t, out, v1alpha1.KindProviderKey, "p3", Retained, "already used by ProviderKey/p1") + if !bytes.Equal(out.Document, first.Document) { + t.Errorf("served document changed:\n%s", out.Document) + } +} + +func TestServedModelKeepsItsNameAgainstAnOlderModel(t *testing.T) { + v := gatewayValidator(t, &fakeValidator{}) + older := model("older", "mock") + older.Fields[v1alpha1.IdentityField] = "one" + newer := model("newer", "mock") + newer.Fields[v1alpha1.IdentityField] = "two" + newer.Created = t0.Add(time.Hour) + first := assemble(t, v, []Source{provider(), older, newer}, nil) + + renamed := model("older", "mock") + renamed.Fields[v1alpha1.IdentityField] = "two" + out := assemble(t, v, []Source{provider(), renamed, newer}, &first) + wantState(t, out, v1alpha1.KindModel, "older", Retained, "already used by Model/newer") + wantState(t, out, v1alpha1.KindModel, "newer", Applied, "") +} + +func TestOldestWinsANameNobodyIsServedUnder(t *testing.T) { + v := gatewayValidator(t, &fakeValidator{}) + older := model("zzz", "mock") + older.Fields[v1alpha1.IdentityField] = "same" + newer := model("aaa", "mock") + newer.Fields[v1alpha1.IdentityField] = "same" + newer.Created = t0.Add(time.Minute) + out := assemble(t, v, []Source{provider(), newer, older}, nil) + wantState(t, out, v1alpha1.KindModel, "zzz", Applied, "") + wantState(t, out, v1alpha1.KindModel, "aaa", Rejected, "already used by Model/zzz") +} + +func TestModelCannotReferenceALosingProviderKey(t *testing.T) { + v := gatewayValidator(t, &fakeValidator{}) + out := assemble(t, v, []Source{ + namedProvider("prod", "openai", "openai", 0), namedProvider("test", "openai", "caller", time.Minute), + model("cheap", "test"), model("main", "prod"), + }, nil) + wantState(t, out, v1alpha1.KindModel, "cheap", Rejected, `ProviderKey "test", which is not served`) + wantState(t, out, v1alpha1.KindModel, "cheap", Rejected, "already used by ProviderKey/prod") + wantState(t, out, v1alpha1.KindModel, "main", Applied, "") + wantModel(t, out, "main", "openai", credOne) +} + +func TestModelResolvesToTheProviderDisplayName(t *testing.T) { + v := gatewayValidator(t, &fakeValidator{}) + m := model("m", "pk") + m.Fields[v1alpha1.IdentityField] = "openai/gpt" + out := assemble(t, v, []Source{namedProvider("pk", "Provider One", "openai", 0), m}, nil) + wantModel(t, out, "openai/gpt", "Provider One", credOne) +} + +func TestModelNamingAMissingProviderKeyIsRejected(t *testing.T) { + v := gatewayValidator(t, &fakeValidator{}) + out := assemble(t, v, []Source{model("dangling", "ghost")}, nil) + wantState(t, out, v1alpha1.KindModel, "dangling", Rejected, `ProviderKey "ghost", which does not exist`) +} + +func TestRetainedModelFollowsItsProviderKeyRename(t *testing.T) { + v := gatewayValidator(t, &fakeValidator{reject: rejectUnknownField}) + first := assemble(t, v, []Source{namedProvider("p", "A", "openai", 0), model("m", "p")}, nil) + out := assemble(t, v, []Source{namedProvider("p", "B", "openai", 0), invalid(model("m", "p"))}, &first) + wantState(t, out, v1alpha1.KindProviderKey, "p", Applied, "") + wantState(t, out, v1alpha1.KindModel, "m", Retained, "no_such_field") + wantModel(t, out, "m", "B", credOne) +} + +func TestRetainedModelKeepsItsServedReference(t *testing.T) { + v := gatewayValidator(t, &fakeValidator{}) + first := assemble(t, v, []Source{namedProvider("p", "A", "openai", 0), model("m", "p")}, nil) + out := assemble(t, v, []Source{namedProvider("p", "A", "openai", 0), model("m", "ghost")}, &first) + wantState(t, out, v1alpha1.KindModel, "m", Retained, `ProviderKey "ghost", which does not exist`) + wantModel(t, out, "m", "A", credOne) +} + +func TestModelNeverInheritsAReplacementProviderKey(t *testing.T) { + v := gatewayValidator(t, &fakeValidator{}) + first := assemble(t, v, []Source{namedProvider("p", "A", "openai", 0), model("m", "p")}, nil) + // p is deleted and q takes its display_name. + out := assemble(t, v, []Source{namedProvider("q", "A", "caller", time.Hour), model("m", "p")}, &first) + wantState(t, out, v1alpha1.KindProviderKey, "q", Applied, "") + wantState(t, out, v1alpha1.KindModel, "m", Rejected, `ProviderKey "p", which does not exist`) + if _, ok := entries(t, out, "models")["m"]; ok { + t.Errorf("m is still served:\n%s", out.Document) + } +} + +func TestModelIsRejectedWithItsProviderKey(t *testing.T) { + v := &fakeValidator{} + first := assemble(t, v, []Source{namedProvider("p", "A", "openai", 0), model("m", "p")}, nil) + // The gateway now refuses p's spec and its served entry. + v.reject = func(k v1alpha1.Kind, _ Entry) string { + if k == v1alpha1.KindProviderKey { + return "refused" + } + return "" + } + out := assemble(t, v, []Source{namedProvider("p", "A", "openai", 0), model("m", "p")}, &first) + wantState(t, out, v1alpha1.KindProviderKey, "p", Rejected, "refused") + wantState(t, out, v1alpha1.KindModel, "m", Rejected, `ProviderKey "p", which is not served`) + wantState(t, out, v1alpha1.KindModel, "m", Rejected, "refused") + if _, ok := entries(t, out, "models")["m"]; ok { + t.Errorf("m is still served:\n%s", out.Document) + } +} + +func TestDollarsInAProviderDisplayNameStayEscaped(t *testing.T) { + v := gatewayValidator(t, &fakeValidator{reject: rejectUnknownField}) + first := assemble(t, v, []Source{namedProvider("p", "a$b", "openai", 0), model("m", "p")}, nil) + wantModel(t, first, "m", "a$$b", credOne) + out := assemble(t, v, []Source{namedProvider("p", "a$b", "openai", 0), invalid(model("m", "p"))}, &first) + wantState(t, out, v1alpha1.KindModel, "m", Retained, "no_such_field") + wantModel(t, out, "m", "a$$b", credOne) +} diff --git a/internal/render/render.go b/internal/render/render.go index a6db668..3b13933 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -19,7 +19,8 @@ type Source struct { Kind v1alpha1.Kind Name string Generation int64 - // Created orders duplicate identities: the older resource wins. + // Created orders the file, and settles a shared identity no claimant + // is served under: the older resource wins. Created time.Time Fields map[string]any } @@ -54,7 +55,10 @@ type Entry = map[string]any type Rendered struct { Source Source Entry Entry - Err string + // ProviderRef is the metadata.name a Model references. Assemble resolves + // it to the display_name that ProviderKey ends up served under. + ProviderRef string + Err string } // sortSources orders sources the way the file lists them: by kind, then @@ -80,55 +84,22 @@ func sortSources(sources []Source) []Source { // into the next one. func All(sources []Source, secrets Secrets, reserved string) []Rendered { ordered := sortSources(sources) - - type identity struct { - kind v1alpha1.Kind - id string - } - // The oldest resource wins a shared identity; the rest are not served. - owners := map[identity]string{} - for _, s := range ordered { - id := identity{s.Kind, s.Identity()} - if _, taken := owners[id]; !taken { - owners[id] = s.Name - } - } - owner := func(s Source) string { return owners[identity{s.Kind, s.Identity()}] } - - // Model references resolve only to ProviderKeys that are served. - providers := map[string]providerRef{} - for _, s := range ordered { - if s.Kind == v1alpha1.KindProviderKey { - providers[s.Name] = providerRef{identity: s.Identity(), owner: owner(s)} - } - } - out := make([]Rendered, 0, len(ordered)) for _, s := range ordered { r := Rendered{Source: s} - if o := owner(s); o != s.Name { - r.Err = fmt.Sprintf("%s %q is already used by %s/%s", v1alpha1.IdentityField, s.Identity(), s.Kind, o) + entry, providerRef, err := one(s, secrets, reserved) + if err != nil { + r.Err = err.Error() } else { - entry, err := one(s, secrets, reserved, providers) - if err != nil { - r.Err = err.Error() - } else { - r.Entry = entry - } + r.Entry, r.ProviderRef = entry, providerRef } out = append(out, r) } return out } -// providerRef is what a Model's provider_key_ref resolves through. -type providerRef struct { - identity string - // owner is the ProviderKey that holds the identity in the file. - owner string -} - -func one(s Source, secrets Secrets, reserved string, providers map[string]providerRef) (Entry, error) { +func one(s Source, secrets Secrets, reserved string) (Entry, string, error) { + var providerRef string fields := make(Entry, len(s.Fields)+1) for k, v := range s.Fields { fields[k] = v @@ -139,57 +110,48 @@ func one(s Source, secrets Secrets, reserved string, providers map[string]provid case v1alpha1.KindProviderKey: for _, inline := range []string{"api_key", "secret"} { if _, ok := fields[inline]; ok { - return nil, fmt.Errorf("set the credential through %s, not `%s`", v1alpha1.APIKeySecretRef, inline) + return nil, "", fmt.Errorf("set the credential through %s, not `%s`", v1alpha1.APIKeySecretRef, inline) } } ref, ok := fields[v1alpha1.APIKeySecretRef] if !ok { - return nil, fmt.Errorf("%s is required", v1alpha1.APIKeySecretRef) + return nil, "", fmt.Errorf("%s is required", v1alpha1.APIKeySecretRef) } delete(fields, v1alpha1.APIKeySecretRef) value, err := secretValue(ref, secrets, reserved, v1alpha1.APIKeySecretRef) if err != nil { - return nil, err + return nil, "", err } fields["api_key"] = value case v1alpha1.KindModel: if _, ok := fields["provider_key_id"]; ok { - return nil, fmt.Errorf("reference the provider key through %s", v1alpha1.ProviderKeyRef) + return nil, "", fmt.Errorf("reference the provider key through %s", v1alpha1.ProviderKeyRef) } if ref, ok := fields[v1alpha1.ProviderKeyRef]; ok { delete(fields, v1alpha1.ProviderKeyRef) m, _ := ref.(map[string]any) - name, _ := m["name"].(string) - if name == "" { - return nil, fmt.Errorf("%s.name is required", v1alpha1.ProviderKeyRef) - } - p, ok := providers[name] - if !ok { - return nil, fmt.Errorf("%s names ProviderKey %q, which does not exist", v1alpha1.ProviderKeyRef, name) - } - if p.owner != name { - return nil, fmt.Errorf("%s names ProviderKey %q, which is not served: %s %q is already used by ProviderKey/%s", - v1alpha1.ProviderKeyRef, name, v1alpha1.IdentityField, p.identity, p.owner) + providerRef, _ = m["name"].(string) + if providerRef == "" { + return nil, "", fmt.Errorf("%s.name is required", v1alpha1.ProviderKeyRef) } - fields["provider_key"] = p.identity } case v1alpha1.KindAPIKey: if _, ok := fields["key_hash"]; ok { - return nil, fmt.Errorf("set the key through %s, not `key_hash`", v1alpha1.KeySecretRef) + return nil, "", fmt.Errorf("set the key through %s, not `key_hash`", v1alpha1.KeySecretRef) } ref, ok := fields[v1alpha1.KeySecretRef] if !ok { - return nil, fmt.Errorf("%s is required", v1alpha1.KeySecretRef) + return nil, "", fmt.Errorf("%s is required", v1alpha1.KeySecretRef) } delete(fields, v1alpha1.KeySecretRef) plaintext, err := secretValue(ref, secrets, reserved, v1alpha1.KeySecretRef) if err != nil { - return nil, err + return nil, "", err } fields["key_hash"] = HashKey(plaintext) } - return EscapeDollars(fields).(Entry), nil + return EscapeDollars(fields).(Entry), providerRef, nil } // HashKey is how the gateway stores a caller key: SHA-256, lowercase hex diff --git a/internal/render/render_test.go b/internal/render/render_test.go index 029ed26..7ae38a6 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -56,12 +56,8 @@ func TestProviderKeyTakesCredentialFromSecretAndEscapesIt(t *testing.T) { } } -func TestModelReferenceResolvesToProviderIdentity(t *testing.T) { +func TestModelReferenceIsLeftForAssemble(t *testing.T) { out := byKey(All([]Source{ - src(v1alpha1.KindProviderKey, "pk", map[string]any{ - v1alpha1.IdentityField: "Provider One", - v1alpha1.APIKeySecretRef: ref("creds", "openai"), - }), src(v1alpha1.KindModel, "m", map[string]any{ v1alpha1.IdentityField: "openai/gpt", v1alpha1.ProviderKeyRef: map[string]any{"name": "pk"}, @@ -71,8 +67,13 @@ func TestModelReferenceResolvesToProviderIdentity(t *testing.T) { if m.Err != "" { t.Fatal(m.Err) } - if m.Entry["provider_key"] != "Provider One" || m.Entry[v1alpha1.IdentityField] != "openai/gpt" { - t.Errorf("entry = %v", m.Entry) + if m.ProviderRef != "pk" { + t.Errorf("ProviderRef = %q", m.ProviderRef) + } + for _, f := range []string{"provider_key", v1alpha1.ProviderKeyRef} { + if _, ok := m.Entry[f]; ok { + t.Errorf("%s is in the entry: %v", f, m.Entry) + } } } @@ -102,7 +103,7 @@ func TestMissingReferencesAreErrors(t *testing.T) { src(v1alpha1.KindProviderKey, "no-key", map[string]any{v1alpha1.APIKeySecretRef: ref("creds", "nope")}), src(v1alpha1.KindProviderKey, "empty", map[string]any{v1alpha1.APIKeySecretRef: ref("creds", "empty")}), src(v1alpha1.KindProviderKey, "unset", map[string]any{}), - src(v1alpha1.KindModel, "dangling", map[string]any{v1alpha1.ProviderKeyRef: map[string]any{"name": "ghost"}}), + src(v1alpha1.KindModel, "nameless", map[string]any{v1alpha1.ProviderKeyRef: map[string]any{}}), src(v1alpha1.KindAPIKey, "keyless", map[string]any{}), }, testSecrets(), "")) want := map[string]string{ @@ -110,7 +111,7 @@ func TestMissingReferencesAreErrors(t *testing.T) { "ProviderKey/no-key": `has no key "nope"`, "ProviderKey/empty": "is empty", "ProviderKey/unset": "api_key_secret_ref is required", - "Model/dangling": `ProviderKey "ghost", which does not exist`, + "Model/nameless": "provider_key_ref.name is required", "APIKey/keyless": "key_secret_ref is required", } for k, substr := range want { @@ -134,19 +135,6 @@ func TestInlineCredentialsAreRefused(t *testing.T) { } } -func TestDuplicateIdentityLosesToOlderResource(t *testing.T) { - older := src(v1alpha1.KindModel, "zzz", map[string]any{v1alpha1.IdentityField: "same"}) - newer := src(v1alpha1.KindModel, "aaa", map[string]any{v1alpha1.IdentityField: "same"}) - newer.Created = t0.Add(time.Minute) - out := byKey(All([]Source{newer, older}, testSecrets(), "")) - if out["Model/zzz"].Err != "" { - t.Errorf("older lost: %s", out["Model/zzz"].Err) - } - if !strings.Contains(out["Model/aaa"].Err, "already used by Model/zzz") { - t.Errorf("newer: %q", out["Model/aaa"].Err) - } -} - func TestOrderIsKindThenAgeThenName(t *testing.T) { b := src(v1alpha1.KindModel, "b", map[string]any{}) a := src(v1alpha1.KindModel, "a", map[string]any{}) @@ -192,27 +180,3 @@ func TestReferencesToTheResourcesSecretAreRefused(t *testing.T) { t.Errorf("unrelated reference refused: %s", out["APIKey/fine"].Err) } } - -func TestModelCannotReferenceALosingProviderKey(t *testing.T) { - prod := src(v1alpha1.KindProviderKey, "openai-prod", map[string]any{ - v1alpha1.IdentityField: "openai", v1alpha1.APIKeySecretRef: ref("creds", "openai"), - }) - test := src(v1alpha1.KindProviderKey, "openai-test", map[string]any{ - v1alpha1.IdentityField: "openai", v1alpha1.APIKeySecretRef: ref("creds", "caller"), - }) - test.Created = t0.Add(time.Minute) - out := byKey(All([]Source{ - test, prod, - src(v1alpha1.KindModel, "cheap", map[string]any{v1alpha1.ProviderKeyRef: map[string]any{"name": "openai-test"}}), - src(v1alpha1.KindModel, "main", map[string]any{v1alpha1.ProviderKeyRef: map[string]any{"name": "openai-prod"}}), - }, testSecrets(), "")) - - cheap := out["Model/cheap"] - if cheap.Entry != nil || !strings.Contains(cheap.Err, `ProviderKey "openai-test", which is not served`) || - !strings.Contains(cheap.Err, "ProviderKey/openai-prod") { - t.Errorf("cheap: entry=%v err=%q", cheap.Entry, cheap.Err) - } - if m := out["Model/main"]; m.Err != "" || m.Entry["provider_key"] != "openai" { - t.Errorf("main: entry=%v err=%q", m.Entry, m.Err) - } -}