From 91cd2de33c8daefd8580b3d3a5caf19e8f284da6 Mon Sep 17 00:00:00 2001 From: Abhishek Choudhary Date: Thu, 12 Nov 2026 14:37:14 +0545 Subject: [PATCH] fix: add provider_key_ref under semantic.classifier A semantic router's classifier needs a typesafe ProviderKey, but the CRD stripped classifier.provider_key_id and added nothing in its place, so no classifier Model could be accepted. Add provider_key_ref {name} under semantic.classifier, required there. Render and Assemble resolve it like the root ref, writing semantic.classifier.provider_key with the served display_name. --- README.md | 2 +- api/v1alpha1/kind.go | 5 ++ config/crd/models.aisix.api7.ai.yaml | 16 +++- internal/render/assemble.go | 77 +++++++++++-------- internal/render/assemble_test.go | 77 ++++++++++++++++++- internal/render/render.go | 109 +++++++++++++++++++++------ internal/render/render_test.go | 50 +++++++++++- internal/schema/crd.go | 78 +++++++++++++------ internal/schema/schema_test.go | 16 ++++ 9 files changed, 344 insertions(+), 86 deletions(-) diff --git a/README.md b/README.md index d7f6052..c0c4823 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ All kinds are namespaced, in `aisix.api7.ai/v1alpha1`. A `spec` is the AISIX res | Kind | Short name | Reference fields | |---|---|---| | `ProviderKey` | `aipk` | `api_key_secret_ref: {name, key}`: the upstream credential | -| `Model` | `aimodel` | `provider_key_ref: {name}`: a ProviderKey's `metadata.name` (direct models only) | +| `Model` | `aimodel` | `provider_key_ref: {name}`: a ProviderKey's `metadata.name`, on direct models and under a semantic router's `semantic.classifier` | | `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, 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. diff --git a/api/v1alpha1/kind.go b/api/v1alpha1/kind.go index 3a2822f..cf5975c 100644 --- a/api/v1alpha1/kind.go +++ b/api/v1alpha1/kind.go @@ -105,3 +105,8 @@ const ( // HashAnnotation carries the served resources-file hash. HashAnnotation = "aisix.api7.ai/resources-hash" ) + +// ProviderKeyRefPaths are the objects in a Model spec that hold a +// ProviderKeyRef: the root, for direct models, and a semantic router's +// classifier. +var ProviderKeyRefPaths = [][]string{nil, {"semantic", "classifier"}} diff --git a/config/crd/models.aisix.api7.ai.yaml b/config/crd/models.aisix.api7.ai.yaml index 70e38d7..ee9cce6 100644 --- a/config/crd/models.aisix.api7.ai.yaml +++ b/config/crd/models.aisix.api7.ai.yaml @@ -303,7 +303,8 @@ spec: provider_key_ref: description: ProviderKey, in the same namespace, this model authenticates with. Direct models need it; routing, ensemble and semantic models - leave it unset. + leave it unset. A semantic router's classifier sets its own under + `semantic.classifier`. properties: name: description: ProviderKey `metadata.name`. @@ -517,6 +518,18 @@ spec: out, or returns an answer that names no route. Same values as `on_embedding_failure`. x-kubernetes-preserve-unknown-fields: true + provider_key_ref: + description: ProviderKey, in the same namespace, that authenticates + the classifier's decision calls. Its `provider` must be + `typesafe`. + properties: + name: + description: ProviderKey `metadata.name`. + minLength: 1 + type: string + required: + - name + type: object timeout_ms: description: Deadline for the decision call, in milliseconds. minimum: 1 @@ -529,6 +542,7 @@ spec: type: string required: - type + - provider_key_ref type: object default: description: Direct model alias used when no route clears its diff --git a/internal/render/assemble.go b/internal/render/assemble.go index c65e99e..aeb7f2a 100644 --- a/internal/render/assemble.go +++ b/internal/render/assemble.go @@ -187,9 +187,9 @@ type item struct { entry Entry // identity the entry is served under. identity string - // providerRef is the metadata.name of the ProviderKey the entry uses. - providerRef string - reason string + // providerRefs are the ProviderKeys the entry uses. + providerRefs []ProviderRef + reason string } // assembly is the working state of one Assemble call. @@ -206,28 +206,30 @@ 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, "", "" + it.state, it.entry, it.identity, it.providerRefs = Rejected, nil, "", nil return } - it.state, it.entry, it.identity, it.providerRef = Retained, e, id, "" + it.state, it.entry, it.identity, it.providerRefs = Retained, e, id, nil 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 + for _, path := range v1alpha1.ProviderKeyRefPaths { + name, ok := objectAt(e, path)[providerKeyField].(string) + if !ok { + continue + } + owner, ok := a.served.providers[unescapeDollars(name)] + if !ok { + a.drop(it, fmt.Sprintf("no ProviderKey owns %s %q", fieldAt(path, providerKeyField), unescapeDollars(name))) + return + } + it.providerRefs = append(it.providerRefs, ProviderRef{Path: path, Name: owner}) } - 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.state, it.entry, it.identity, it.providerRefs = Rejected, nil, "", nil it.reason += "; the previously served version is no longer valid either: " + reason } @@ -263,23 +265,16 @@ func (a *assembly) claim() bool { return changed } -// resolve points each Model at the display_name its ProviderKey is served -// under now. +// resolve points each Model at the display_names its ProviderKeys are +// served under now. func (a *assembly) resolve() bool { changed := false for _, it := range a.items { - if it.state == Rejected || it.providerRef == "" { + if it.state == Rejected { 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) + problem := a.bind(it) + if problem == "" { continue } if it.state == Applied { @@ -292,13 +287,29 @@ func (a *assembly) resolve() bool { return changed } +// bind writes each ref's served display_name into the entry, or says why +// one has none. +func (a *assembly) bind(it *item) string { + for _, r := range it.providerRefs { + p := a.providers[r.Name] + switch { + case p == nil: + return fmt.Sprintf("%s names ProviderKey %q, which does not exist", r.Field(), r.Name) + case p.state == Rejected: + return fmt.Sprintf("%s names ProviderKey %q, which is not served: %s", r.Field(), r.Name, p.reason) + } + it.setProviderKey(r.Path, p.identity) + } + return "" +} + // 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 +func (it *item) setProviderKey(path []string, identity string) { + e, m := clonePath(it.entry, path) + if m == nil { + return } - e[providerKeyField] = EscapeDollars(identity) + m[providerKeyField] = EscapeDollars(identity) it.entry = e } @@ -321,7 +332,7 @@ func Assemble(ctx context.Context, rendered []Rendered, served Served, v validat items := make([]*item, len(rendered)) 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(), providerRef: r.ProviderRef} + it := &item{source: r.Source, state: Applied, entry: r.Entry, identity: r.Source.Identity(), providerRefs: r.ProviderRefs} if r.Err != "" { a.fallback(it, r.Err) } diff --git a/internal/render/assemble_test.go b/internal/render/assemble_test.go index ca59a24..72b5731 100644 --- a/internal/render/assemble_test.go +++ b/internal/render/assemble_test.go @@ -41,8 +41,10 @@ func (f *fakeValidator) Validate(_ context.Context, doc []byte) ([]validate.Load for _, k := range v1alpha1.Kinds { for i, e := range list(root, k.Collection()) { scope := fmt.Sprintf("%s[%d] (%q)", k.Collection(), i, e[v1alpha1.IdentityField]) - if name, ok := e["provider_key"].(string); ok && !providers[name] { - errs = append(errs, validate.LoadError{Scope: scope, Message: fmt.Sprintf("`provider_key` references unknown provider key %q", name)}) + for _, path := range v1alpha1.ProviderKeyRefPaths { + if name, ok := objectAt(e, path)["provider_key"].(string); ok && !providers[name] { + errs = append(errs, validate.LoadError{Scope: scope, Message: fmt.Sprintf("`%s` references unknown provider key %q", fieldAt(path, "provider_key"), name)}) + } } if f.reject != nil { if msg := f.reject(k, e); msg != "" { @@ -575,6 +577,77 @@ func TestModelIsRejectedWithItsProviderKey(t *testing.T) { } } +// typesafe is a ProviderKey for a classifier's decision calls. +func typesafe(name, display string) Source { + return src(v1alpha1.KindProviderKey, name, map[string]any{ + "provider": "typesafe", v1alpha1.IdentityField: display, + v1alpha1.APIKeySecretRef: ref("creds", "caller"), + }) +} + +// router is a semantic router whose classifier uses providerRef. +func router(name, providerRef string) Source { + return src(v1alpha1.KindModel, name, map[string]any{ + "semantic": map[string]any{ + "classifier": map[string]any{ + "type": "jev", + v1alpha1.ProviderKeyRef: map[string]any{"name": providerRef}, + }, + "default": "chat", + "routes": []any{map[string]any{ + "name": "billing", "target": "chat", "description": "billing questions", + }}, + }, + }) +} + +// wantClassifier checks which provider key a router's classifier is served on. +func wantClassifier(t *testing.T, o Outcome, model, providerKey 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 got := objectAt(m, v1alpha1.ProviderKeyRefPaths[1])["provider_key"]; got != providerKey { + t.Errorf("model %s: classifier provider_key = %v, want %s", model, got, providerKey) + } +} + +func TestClassifierResolvesToTheProviderDisplayName(t *testing.T) { + v := gatewayValidator(t, &fakeValidator{}) + out := assemble(t, v, []Source{provider(), typesafe("ts", "TypeSafe Key"), model("chat", "mock"), router("router", "ts")}, nil) + wantState(t, out, v1alpha1.KindModel, "router", Applied, "") + wantClassifier(t, out, "router", "TypeSafe Key") +} + +func TestClassifierNamingAMissingProviderKeyIsRejected(t *testing.T) { + v := gatewayValidator(t, &fakeValidator{}) + out := assemble(t, v, []Source{provider(), model("chat", "mock"), router("router", "ghost")}, nil) + wantState(t, out, v1alpha1.KindModel, "router", Rejected, `semantic.classifier.provider_key_ref names ProviderKey "ghost", which does not exist`) + wantState(t, out, v1alpha1.KindModel, "chat", Applied, "") +} + +func TestRetainedClassifierFollowsItsProviderKeyRename(t *testing.T) { + v := gatewayValidator(t, &fakeValidator{reject: rejectUnknownField}) + first := assemble(t, v, []Source{provider(), typesafe("ts", "A"), model("chat", "mock"), router("router", "ts")}, nil) + out := assemble(t, v, []Source{provider(), typesafe("ts", "B"), model("chat", "mock"), invalid(router("router", "ts"))}, &first) + wantState(t, out, v1alpha1.KindProviderKey, "ts", Applied, "") + wantState(t, out, v1alpha1.KindModel, "router", Retained, "no_such_field") + wantClassifier(t, out, "router", "B") +} + +func TestRetainedClassifierIsRejectedWithItsProviderKey(t *testing.T) { + v := &fakeValidator{} + first := assemble(t, v, []Source{provider(), typesafe("ts", "A"), model("chat", "mock"), router("router", "ts")}, nil) + // ts is deleted, and the router's new spec names it. + out := assemble(t, v, []Source{provider(), model("chat", "mock"), router("router", "ts")}, &first) + wantState(t, out, v1alpha1.KindModel, "router", Rejected, `semantic.classifier.provider_key_ref names ProviderKey "ts", which does not exist`) + wantState(t, out, v1alpha1.KindModel, "router", Rejected, "the previously served version is no longer valid either") + if _, ok := entries(t, out, "models")["router"]; ok { + t.Errorf("router 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) diff --git a/internal/render/render.go b/internal/render/render.go index 3b13933..f810909 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -55,10 +55,60 @@ type Entry = map[string]any type Rendered struct { Source Source Entry Entry - // 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 + // ProviderRefs are the ProviderKeys a Model references. Assemble + // resolves each to the display_name that ProviderKey ends up served under. + ProviderRefs []ProviderRef + Err string +} + +// ProviderRef is one ProviderKeyRef in a Model spec. +type ProviderRef struct { + // Path is the object holding the ref, one of v1alpha1.ProviderKeyRefPaths. + Path []string + // Name is the ProviderKey's metadata.name. + Name string +} + +// Field is the ref's spec path, for messages. +func (r ProviderRef) Field() string { return fieldAt(r.Path, v1alpha1.ProviderKeyRef) } + +func fieldAt(path []string, field string) string { + return strings.Join(append(append([]string(nil), path...), field), ".") +} + +// objectAt returns the object at path in m, or nil. +func objectAt(m map[string]any, path []string) map[string]any { + for _, p := range path { + next, ok := m[p].(map[string]any) + if !ok { + return nil + } + m = next + } + return m +} + +// clonePath copies e and every object along path, so the object at path +// can change without touching e. That object is nil when path leads to none. +func clonePath(e Entry, path []string) (Entry, map[string]any) { + out := make(Entry, len(e)+1) + for k, v := range e { + out[k] = v + } + m := out + for _, p := range path { + next, ok := m[p].(map[string]any) + if !ok { + return out, nil + } + c := make(map[string]any, len(next)+1) + for k, v := range next { + c[k] = v + } + m[p] = c + m = c + } + return out, m } // sortSources orders sources the way the file lists them: by kind, then @@ -87,19 +137,19 @@ func All(sources []Source, secrets Secrets, reserved string) []Rendered { out := make([]Rendered, 0, len(ordered)) for _, s := range ordered { r := Rendered{Source: s} - entry, providerRef, err := one(s, secrets, reserved) + entry, providerRefs, err := one(s, secrets, reserved) if err != nil { r.Err = err.Error() } else { - r.Entry, r.ProviderRef = entry, providerRef + r.Entry, r.ProviderRefs = entry, providerRefs } out = append(out, r) } return out } -func one(s Source, secrets Secrets, reserved string) (Entry, string, error) { - var providerRef string +func one(s Source, secrets Secrets, reserved string) (Entry, []ProviderRef, error) { + var providerRefs []ProviderRef fields := make(Entry, len(s.Fields)+1) for k, v := range s.Fields { fields[k] = v @@ -110,48 +160,59 @@ func one(s Source, secrets Secrets, reserved string) (Entry, string, error) { 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, 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, 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, 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) - } - if ref, ok := fields[v1alpha1.ProviderKeyRef]; ok { - delete(fields, v1alpha1.ProviderKeyRef) - m, _ := ref.(map[string]any) - providerRef, _ = m["name"].(string) - if providerRef == "" { - return nil, "", fmt.Errorf("%s.name is required", v1alpha1.ProviderKeyRef) + for _, path := range v1alpha1.ProviderKeyRefPaths { + var m map[string]any + fields, m = clonePath(fields, path) + if m == nil { + continue + } + r := ProviderRef{Path: path} + if _, ok := m["provider_key_id"]; ok { + return nil, nil, fmt.Errorf("reference the provider key through %s", r.Field()) + } + ref, ok := m[v1alpha1.ProviderKeyRef] + if !ok { + continue + } + delete(m, v1alpha1.ProviderKeyRef) + refObj, _ := ref.(map[string]any) + r.Name, _ = refObj["name"].(string) + if r.Name == "" { + return nil, nil, fmt.Errorf("%s.name is required", r.Field()) } + providerRefs = append(providerRefs, r) } 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, 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, 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, nil, err } fields["key_hash"] = HashKey(plaintext) } - return EscapeDollars(fields).(Entry), providerRef, nil + return EscapeDollars(fields).(Entry), providerRefs, 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 7ae38a6..66b8145 100644 --- a/internal/render/render_test.go +++ b/internal/render/render_test.go @@ -67,8 +67,8 @@ func TestModelReferenceIsLeftForAssemble(t *testing.T) { if m.Err != "" { t.Fatal(m.Err) } - if m.ProviderRef != "pk" { - t.Errorf("ProviderRef = %q", m.ProviderRef) + if len(m.ProviderRefs) != 1 || m.ProviderRefs[0].Name != "pk" || len(m.ProviderRefs[0].Path) != 0 { + t.Errorf("ProviderRefs = %+v", m.ProviderRefs) } for _, f := range []string{"provider_key", v1alpha1.ProviderKeyRef} { if _, ok := m.Entry[f]; ok { @@ -77,6 +77,52 @@ func TestModelReferenceIsLeftForAssemble(t *testing.T) { } } +func classifierSpec(ref map[string]any) map[string]any { + classifier := map[string]any{"type": "jev"} + if ref != nil { + classifier[v1alpha1.ProviderKeyRef] = ref + } + return map[string]any{"semantic": map[string]any{"classifier": classifier}} +} + +func TestClassifierReferenceIsLeftForAssemble(t *testing.T) { + s := src(v1alpha1.KindModel, "router", classifierSpec(map[string]any{"name": "ts"})) + m := byKey(All([]Source{s}, testSecrets(), ""))["Model/router"] + if m.Err != "" { + t.Fatal(m.Err) + } + if len(m.ProviderRefs) != 1 || m.ProviderRefs[0].Name != "ts" || m.ProviderRefs[0].Field() != "semantic.classifier.provider_key_ref" { + t.Errorf("ProviderRefs = %+v", m.ProviderRefs) + } + classifier := m.Entry["semantic"].(map[string]any)["classifier"].(map[string]any) + for _, f := range []string{"provider_key", v1alpha1.ProviderKeyRef} { + if _, ok := classifier[f]; ok { + t.Errorf("%s is in the classifier: %v", f, classifier) + } + } + if _, ok := s.Fields["semantic"].(map[string]any)["classifier"].(map[string]any)[v1alpha1.ProviderKeyRef]; !ok { + t.Error("the source was mutated") + } +} + +func TestClassifierReferenceErrors(t *testing.T) { + byID := classifierSpec(nil) + byID["semantic"].(map[string]any)["classifier"].(map[string]any)["provider_key_id"] = "x" + out := byKey(All([]Source{ + src(v1alpha1.KindModel, "nameless", classifierSpec(map[string]any{})), + src(v1alpha1.KindModel, "by-id", byID), + }, testSecrets(), "")) + want := map[string]string{ + "Model/nameless": "semantic.classifier.provider_key_ref.name is required", + "Model/by-id": "reference the provider key through semantic.classifier.provider_key_ref", + } + for k, substr := range want { + if !strings.Contains(out[k].Err, substr) { + t.Errorf("%s: err %q lacks %q", k, out[k].Err, substr) + } + } +} + func TestAPIKeyIsHashed(t *testing.T) { out := byKey(All([]Source{src(v1alpha1.KindAPIKey, "k", map[string]any{ v1alpha1.KeySecretRef: ref("creds", "caller"), diff --git a/internal/schema/crd.go b/internal/schema/crd.go index 528164b..d3eeef7 100644 --- a/internal/schema/crd.go +++ b/internal/schema/crd.go @@ -56,35 +56,50 @@ func secretKeyRef(description string) obj { } type addedField struct { + // at is the object the field is added to; empty for the spec root. + at []string name string schema obj required bool } +func providerKeyRef(description string) obj { + return obj{ + "type": "object", + "description": description, + "required": []any{"name"}, + "properties": obj{ + "name": obj{"type": "string", "minLength": 1, "description": "ProviderKey `metadata.name`."}, + }, + } +} + +// providerKeyRefDescriptions match v1alpha1.ProviderKeyRefPaths. +var providerKeyRefDescriptions = []string{ + "ProviderKey, in the same namespace, this model authenticates with. Direct models need it; routing, ensemble and semantic models leave it unset. A semantic router's classifier sets its own under `semantic.classifier`.", + "ProviderKey, in the same namespace, that authenticates the classifier's decision calls. Its `provider` must be `typesafe`.", +} + // addedFields are the reference fields the controller resolves. func addedFields(k v1alpha1.Kind) []addedField { switch k { case v1alpha1.KindProviderKey: return []addedField{{ + nil, v1alpha1.APIKeySecretRef, secretKeyRef("Secret key holding the upstream provider credential. The controller writes it as `api_key`."), true, }} case v1alpha1.KindModel: - return []addedField{{ - v1alpha1.ProviderKeyRef, - obj{ - "type": "object", - "description": "ProviderKey, in the same namespace, this model authenticates with. Direct models need it; routing, ensemble and semantic models leave it unset.", - "required": []any{"name"}, - "properties": obj{ - "name": obj{"type": "string", "minLength": 1, "description": "ProviderKey `metadata.name`."}, - }, - }, - false, - }} + var out []addedField + for i, at := range v1alpha1.ProviderKeyRefPaths { + // Only a nested ref is required: its object always needs a key. + out = append(out, addedField{at, v1alpha1.ProviderKeyRef, providerKeyRef(providerKeyRefDescriptions[i]), len(at) > 0}) + } + return out case v1alpha1.KindAPIKey: return []addedField{{ + nil, v1alpha1.KeySecretRef, secretKeyRef("Secret key holding the caller's plaintext API key. The controller writes only its SHA-256 hash."), true, @@ -93,6 +108,31 @@ func addedFields(k v1alpha1.Kind) []addedField { return nil } +// addFields adds a kind's reference fields to its structural spec schema. +func addFields(k v1alpha1.Kind, spec obj) { + for _, f := range addedFields(k) { + node := spec + for _, p := range f.at { + props, _ := node["properties"].(obj) + next, ok := props[p].(obj) + if !ok { + panic(fmt.Sprintf("%s: no object at %s for %s", k, strings.Join(f.at, "."), f.name)) + } + node = next + } + props, _ := node["properties"].(obj) + if props == nil { + props = obj{} + node["properties"] = props + } + props[f.name] = f.schema + if f.required { + required, _ := node["required"].([]any) + node["required"] = append(required, f.name) + } + } +} + // LoadSchema reads a vendored AISIX schema. func LoadSchema(dir string, k v1alpha1.Kind) (obj, error) { b, err := os.ReadFile(filepath.Join(dir, k.SchemaName()+".schema.json")) @@ -128,20 +168,10 @@ func Shape(k v1alpha1.Kind, root obj) obj { } } props, _ := root["properties"].(obj) - if props == nil { - props = obj{} - root["properties"] = props - } if identity, ok := props[v1alpha1.IdentityField].(obj); ok { base, _ := identity["description"].(string) identity["description"] = strings.TrimSpace(base + " Defaults to `metadata.name`.") } - for _, f := range addedFields(k) { - props[f.name] = f.schema - if f.required { - required = append(required, f.name) - } - } if len(required) == 0 { delete(root, "required") } else { @@ -225,6 +255,8 @@ func statusSchema() obj { // ResourceSchema is the full structural schema of a kind's custom resource. func ResourceSchema(k v1alpha1.Kind, aisixSchema obj) obj { + spec := ToStructural(Shape(k, aisixSchema)) + addFields(k, spec) return obj{ "type": "object", "description": fmt.Sprintf("AISIX %s resource.", k), @@ -233,7 +265,7 @@ func ResourceSchema(k v1alpha1.Kind, aisixSchema obj) obj { "apiVersion": obj{"type": "string"}, "kind": obj{"type": "string"}, "metadata": obj{"type": "object"}, - "spec": ToStructural(Shape(k, aisixSchema)), + "spec": spec, "status": statusSchema(), }, } diff --git a/internal/schema/schema_test.go b/internal/schema/schema_test.go index c164896..b0d91fd 100644 --- a/internal/schema/schema_test.go +++ b/internal/schema/schema_test.go @@ -159,6 +159,22 @@ func TestIDFormReferencesAreRemoved(t *testing.T) { } } +func TestClassifierHasAProviderKeyRef(t *testing.T) { + classifier := specSchema(t, v1alpha1.KindModel) + for _, p := range v1alpha1.ProviderKeyRefPaths[1] { + classifier = classifier["properties"].(obj)[p].(obj) + } + if _, ok := classifier["properties"].(obj)[v1alpha1.ProviderKeyRef]; !ok { + t.Fatalf("classifier lacks %s", v1alpha1.ProviderKeyRef) + } + if !has(required(classifier), v1alpha1.ProviderKeyRef) { + t.Errorf("classifier does not require %s", v1alpha1.ProviderKeyRef) + } + if has(required(specSchema(t, v1alpha1.KindModel)), v1alpha1.ProviderKeyRef) { + t.Errorf("Model requires a root %s", v1alpha1.ProviderKeyRef) + } +} + func TestIdentityIsOptional(t *testing.T) { for _, k := range v1alpha1.Kinds { s := specSchema(t, k)