From be5eaedf849252002714dd752984a65dd0366d45 Mon Sep 17 00:00:00 2001 From: ialexeze Date: Wed, 5 Aug 2026 04:00:36 +0000 Subject: [PATCH 1/3] feat: shift Control Center off the full-CR API onto target mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Control Center's IDP create form spoke the old full-CR contract — it constructed apiVersion/kind/metadata/spec client-side and server-side, and fetched schema from a path-suffixed endpoint the gateway never actually served. It now speaks target mode like every other Apply API caller: submits {"target": "...", ...fields}, fetches schema from GET /api/v1/schema?target=, and never builds a CR — the gateway does that from idp.fields/idp.additionalFields. The runtime's /katalog response now carries a target field per CRD (CRDEntry.IDPTargetOrEmpty) alongside idpEnabled, so Control Center resolves target the same way every other caller does instead of deriving one from Kind/GVK. Closed a gap in the target-mode API itself found along the way: BuildCRFromTarget had no fallback for a caller-supplied name when idp.name isn't declared, unlike full CR mode's metadata.name — every target-mode request against such a CRD was rejected regardless of what the caller sent. It now falls back to a flat "name" field the same way. --- .../cc/assets/templates/idp_form.html | 29 +-- cmd/controlcenter/cc/controlcenter.go | 213 ++++-------------- cmd/controlcenter/cc/idp_fields_test.go | 139 ++---------- cmd/controlcenter/cc/types.go | 34 ++- cmd/controlcenter/docs/02-routing.md | 7 +- cmd/controlcenter/docs/03-data-flow.md | 9 +- cmd/controlcenter/docs/06-idp-form.md | 123 +++++----- pkg/gateway/applyapi/target.go | 7 + pkg/gateway/applyapi/target_helpers_test.go | 47 ++++ pkg/runtime/kordinator/crd_health_handers.go | 20 +- pkg/types/types_idp_target.go | 10 + 11 files changed, 240 insertions(+), 398 deletions(-) diff --git a/cmd/controlcenter/cc/assets/templates/idp_form.html b/cmd/controlcenter/cc/assets/templates/idp_form.html index 5d8c11f8..b11946bd 100644 --- a/cmd/controlcenter/cc/assets/templates/idp_form.html +++ b/cmd/controlcenter/cc/assets/templates/idp_form.html @@ -233,13 +233,13 @@

Create {{ .Kind }}

{{ if eq .InputType "checkbox" }}
-
{{ else if eq .InputType "select" }} - {{ else if eq .InputType "number" }} - {{ else }} - {{ end }} @@ -293,6 +291,7 @@

Create {{ .Kind }}

var btnPreview = document.getElementById('idp-preview'); var status = document.getElementById('idp-status'); var backURL = {{ .BackURL | js }}; + var target = {{ .Target | js }}; // ── Condition evaluation ──────────────────────────────────────────────── function evalCond(cond) { @@ -422,13 +421,11 @@

Create {{ .Kind }}

} // ── Payload collection ────────────────────────────────────────────────── - // Each field's data-source ("spec" | "label" | "annotation") decides which - // bucket it lands in — the server writes each bucket to a different part - // of the CR (spec.*, metadata.labels, metadata.annotations). Labels and - // annotations are always strings, regardless of data-type, since that's - // all Kubernetes metadata values can be. + // Flat target-mode payload — the gateway resolves which field goes where + // (spec, label, annotation) from the Katalog's idp.fields declaration; + // the form just submits the field names and values it was given. function collectPayload() { - var spec = {}, labels = {}, annotations = {}, name = ''; + var payload = { target: target }, name = ''; form.querySelectorAll('input, select').forEach(function (el) { var n = el.name; if (!n) return; @@ -437,16 +434,14 @@

Create {{ .Kind }}

if (wrap && wrap.style.display === 'none') return; if (el.disabled) return; var t = el.getAttribute('data-type'); - var source = el.getAttribute('data-source') || 'spec'; var v = (t === 'boolean') ? el.checked : (t === 'number') ? (el.value !== '' ? Number(el.value) : undefined) : el.value; if (v === undefined || v === '') return; - if (source === 'label') { labels[n] = String(v); } - else if (source === 'annotation') { annotations[n] = String(v); } - else { spec[n] = v; } + payload[n] = v; }); - return { name: name, spec: spec, labels: labels, annotations: annotations }; + if (name) payload.name = name; + return payload; } // ── Submit / Preview ──────────────────────────────────────────────────── diff --git a/cmd/controlcenter/cc/controlcenter.go b/cmd/controlcenter/cc/controlcenter.go index cd130de1..11f8e9f7 100644 --- a/cmd/controlcenter/cc/controlcenter.go +++ b/cmd/controlcenter/cc/controlcenter.go @@ -324,8 +324,8 @@ func (cc *ControlCenter) ServeHTTP(w http.ResponseWriter, r *http.Request) { cc.handleIDPApply(w, r) case strings.HasPrefix(path, "/api/idp/schema/"): - kind := strings.TrimPrefix(path, "/api/idp/schema/") - cc.handleIDPSchema(w, r, kind) + target := strings.TrimPrefix(path, "/api/idp/schema/") + cc.handleIDPSchema(w, r, target) case strings.HasPrefix(path, "/katalog/"): // Strip leading slash and split @@ -874,15 +874,15 @@ func (cc *ControlCenter) handleCRList(w http.ResponseWriter, r *http.Request, in }) } -// handleIDPSchema proxies GET /api/v1/schema/{kind} from the gateway. +// handleIDPSchema proxies GET /api/v1/schema?target= from the gateway. // The gateway token is stored server-side — the browser never sees it. -func (cc *ControlCenter) handleIDPSchema(w http.ResponseWriter, r *http.Request, kind string) { +func (cc *ControlCenter) handleIDPSchema(w http.ResponseWriter, r *http.Request, target string) { inst := cc.firstInstance() if inst == nil || inst.GatewayEndpoint == "" { http.Error(w, `{"error":"no gateway configured"}`, http.StatusServiceUnavailable) return } - cc.proxyIDPRequest(w, r, inst.GatewayEndpoint+"/api/v1/schema/"+kind, http.MethodGet, nil) + cc.proxyIDPRequest(w, r, inst.GatewayEndpoint+"/api/v1/schema?target="+url.QueryEscape(target), http.MethodGet, nil) } // handleIDPApply proxies POST /api/v1/apply to the gateway. @@ -963,22 +963,26 @@ func (cc *ControlCenter) handleIDPCreateForm(w http.ResponseWriter, r *http.Requ break } } - if crdSummary == nil || !crdSummary.IdpEnabled { + if crdSummary == nil || !crdSummary.IdpEnabled || crdSummary.Target == "" { http.Redirect(w, r, backURL, http.StatusSeeOther) return } + target := crdSummary.Target + // Kind/APIVersion are display-only (page header) — the gateway builds + // the CR from target, it doesn't need them from the form. kind, apiVersion := idpParseGVK(crdSummary.GVK) if r.Method == http.MethodPost { - cc.handleIDPApplyForm(w, r, inst, kind, apiVersion) + cc.handleIDPApplyForm(w, r, inst, target) return } - sections, fetchErr := cc.fetchIDPFields(inst, crdName, kind) + sections, fetchErr := cc.fetchIDPFields(inst, target) data := IDPFormData{ KatalogName: katalogName, CRDName: crdName, + Target: target, Kind: kind, APIVersion: apiVersion, BackURL: backURL, @@ -1004,11 +1008,10 @@ const ( FieldTypeEnum FieldType = "enum" ) -// idpFieldHint mirrors orktypes.IDPFieldConfig — the presentation-hint shape -// shared by idp.fields, idp.additionalFields.labels, and -// idp.additionalFields.annotations. Type/Enum only matter for the latter -// two: spec fields always infer type/enum from the CRD's OpenAPI schema -// (schemaResp.Properties) instead. +// idpFieldHint mirrors one entry of pkg/gateway/applyapi.SchemaResponse.Fields +// (itself orktypes.IDPFieldConfig) — the gateway's flat, caller-facing field +// contract. It doesn't distinguish where a field routes to (spec, label, +// annotation) — the gateway resolves that from the Katalog, not the caller. type idpFieldHint struct { Label string `json:"label"` Placeholder string `json:"placeholder"` @@ -1023,76 +1026,17 @@ type idpFieldHint struct { Enum []string `json:"enum"` } -// idpSchemaProperty mirrors one entry of pkg/gateway/applyapi.SchemaResponse.Properties -// — a CRD spec field as reported by the CRD's own OpenAPI schema. Type is always a JSON -// Schema base type ("string", "integer", "number", "boolean") — CRD schemas never use -// "enum" as a type; Enum is a constraint alongside "string", not a type of its own. -type idpSchemaProperty struct { - Type string `json:"type"` - Enum []string `json:"enum"` - Description string `json:"description"` - Default interface{} `json:"default"` -} - -// buildSpecIDPField builds an IDPField for one idp.fields entry (or an -// undeclared spec property with no hint). Unlike additionalFields, -// type/enum come from the CRD's OpenAPI schema (prop), not from the hint — -// a schema enum is reported as Type: "string" with Enum populated, never -// Type: "enum". -func buildSpecIDPField(name string, prop idpSchemaProperty, hint idpFieldHint, required bool) IDPField { - label := hint.Label - if label == "" { - label = strings.ToUpper(name[:1]) + name[1:] - } - f := IDPField{ - Name: name, - Label: label, - Hint: hint.Hint, - Placeholder: hint.Placeholder, - Required: required || hint.Required, - Category: hint.Category, - Disabled: hint.Disabled, - Source: IDPFieldSourceSpec, - } - if f.Hint == "" { - f.Hint = prop.Description - } - // Pre-populate from CRD schema default: - if prop.Default != nil { - f.Default = fmt.Sprintf("%v", prop.Default) - } - // Encode when/anyOf as JSON strings for the template to embed as data attributes. - if len(hint.When) > 0 { - if b, err := json.Marshal(hint.When); err == nil { - f.WhenJSON = string(b) - } - } - if len(hint.AnyOf) > 0 { - if b, err := json.Marshal(hint.AnyOf); err == nil { - f.AnyOfJSON = string(b) - } - } - switch { - case len(prop.Enum) > 0: - f.InputType = "select" - f.Enum = prop.Enum - case prop.Type == string(FieldTypeBoolean): - f.InputType = "checkbox" - case prop.Type == string(FieldTypeInteger) || prop.Type == string(FieldTypeNumber): - f.InputType = "number" - default: - f.InputType = "text" - } - return f -} - -// buildAdditionalIDPField builds an IDPField for one idp.additionalFields -// entry. Unlike spec fields, type/enum come from the hint itself — there's -// no CRD schema to infer them from. -func buildAdditionalIDPField(name string, hint idpFieldHint, source string) IDPField { +// buildIDPField builds an IDPField for one gateway schema field entry. +// Type/enum come from the field's own declared type — the gateway's flat +// schema doesn't distinguish where a field routes to (spec, label, +// annotation), so neither does the form. +func buildIDPField(name string, hint idpFieldHint) IDPField { label := hint.Label if label == "" { label = name + if len(label) > 0 { + label = strings.ToUpper(label[:1]) + label[1:] + } } f := IDPField{ Name: name, @@ -1102,8 +1046,8 @@ func buildAdditionalIDPField(name string, hint idpFieldHint, source string) IDPF Required: hint.Required, Category: hint.Category, Disabled: hint.Disabled, - Source: source, } + // Encode when/anyOf as JSON strings for the template to embed as data attributes. if len(hint.When) > 0 { if b, err := json.Marshal(hint.When); err == nil { f.WhenJSON = string(b) @@ -1114,7 +1058,6 @@ func buildAdditionalIDPField(name string, hint idpFieldHint, source string) IDPF f.AnyOfJSON = string(b) } } - switch { case hint.Type == string(FieldTypeEnum) && len(hint.Enum) > 0: f.InputType = "select" @@ -1129,10 +1072,8 @@ func buildAdditionalIDPField(name string, hint idpFieldHint, source string) IDPF return f } -// fetchIDPFields fetches the CRD schema from the gateway. The gateway merges -// the OpenAPI spec properties with idp.fields hints (labels, hints, order) -// from the Katalog, so a single call is enough. -func (cc *ControlCenter) fetchIDPFields(inst *Instance, crdName, kind string) ([]IDPSection, error) { +// fetchIDPFields fetches the flat field schema for target from the gateway. +func (cc *ControlCenter) fetchIDPFields(inst *Instance, target string) ([]IDPSection, error) { if inst.GatewayEndpoint == "" { return nil, fmt.Errorf("no gateway endpoint configured") } @@ -1140,8 +1081,7 @@ func (cc *ControlCenter) fetchIDPFields(inst *Instance, crdName, kind string) ([ return nil, fmt.Errorf("GATEWAY_TOKEN not set") } - // ── Schema + idpFields from a single gateway call ──────────────────────── - req, _ := http.NewRequest(http.MethodGet, inst.GatewayEndpoint+"/api/v1/schema/"+strings.ToLower(kind), nil) //nolint:noctx + req, _ := http.NewRequest(http.MethodGet, inst.GatewayEndpoint+"/api/v1/schema?target="+url.QueryEscape(target), nil) //nolint:noctx req.Header.Set("Authorization", "Bearer "+cc.gatewayToken) client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) @@ -1154,56 +1094,22 @@ func (cc *ControlCenter) fetchIDPFields(inst *Instance, crdName, kind string) ([ return nil, fmt.Errorf("%s", strings.TrimSpace(string(body))) } - // SchemaResponse mirrors pkg/gateway/schema.SchemaResponse. + // schemaResp mirrors pkg/gateway/applyapi.SchemaResponse — a flat field + // map, no CRD-schema/bucket distinction. var schemaResp struct { - Properties map[string]idpSchemaProperty `json:"properties"` - Required []string `json:"required"` - IgnoreFields []string `json:"ignoreFields"` - IDPFields map[string]idpFieldHint `json:"idpFields"` - AdditionalLabels map[string]idpFieldHint `json:"additionalLabels"` - AdditionalAnnotations map[string]idpFieldHint `json:"additionalAnnotations"` + Fields map[string]idpFieldHint `json:"fields"` } if err := json.NewDecoder(resp.Body).Decode(&schemaResp); err != nil { return nil, err } - requiredSet := map[string]bool{} - for _, name := range schemaResp.Required { - requiredSet[name] = true - } - ignoreSet := map[string]bool{} - for _, name := range schemaResp.IgnoreFields { - ignoreSet[name] = true - } - type orderedField struct { field IDPField order int } var ordered []orderedField - - for name, prop := range schemaResp.Properties { - if ignoreSet[name] { - continue - } - hint := schemaResp.IDPFields[name] - f := buildSpecIDPField(name, prop, hint, requiredSet[name]) - ordered = append(ordered, orderedField{field: f, order: hint.Order}) - } - - // idp.additionalFields — labels and annotations. No CRD schema counterpart, - // so type/enum come from the hint itself instead of schemaResp.Properties. - for name, hint := range schemaResp.AdditionalLabels { - ordered = append(ordered, orderedField{ - field: buildAdditionalIDPField(name, hint, IDPFieldSourceLabel), - order: hint.Order, - }) - } - for name, hint := range schemaResp.AdditionalAnnotations { - ordered = append(ordered, orderedField{ - field: buildAdditionalIDPField(name, hint, IDPFieldSourceAnnotation), - order: hint.Order, - }) + for name, hint := range schemaResp.Fields { + ordered = append(ordered, orderedField{field: buildIDPField(name, hint), order: hint.Order}) } sort.Slice(ordered, func(i, j int) bool { @@ -1220,17 +1126,13 @@ func (cc *ControlCenter) fetchIDPFields(inst *Instance, crdName, kind string) ([ } return ordered[i].field.Name < ordered[j].field.Name }) - // Group sorted fields into sections by Group name. + // Group sorted fields into sections by Category name. var sections []IDPSection for _, o := range ordered { f := o.field title := f.Category if title == "" { - if f.Source == "spec" { - title = "Spec" - } else { - title = "Additional Fields" - } + title = "Fields" } if len(sections) == 0 || sections[len(sections)-1].Title != title { sections = append(sections, IDPSection{Title: title}) @@ -1240,51 +1142,28 @@ func (cc *ControlCenter) fetchIDPFields(inst *Instance, crdName, kind string) ([ return sections, nil } -// handleIDPApplyForm processes the IDP form POST: builds a CR and forwards to the gateway. -func (cc *ControlCenter) handleIDPApplyForm(w http.ResponseWriter, r *http.Request, inst *Instance, kind, apiVersion string) { - var body struct { - Name string `json:"name"` - Spec map[string]any `json:"spec"` - Labels map[string]string `json:"labels"` - Annotations map[string]string `json:"annotations"` - } +// handleIDPApplyForm processes the IDP form POST: forwards the flat +// target-mode payload the form submitted to the gateway's Apply API. The +// gateway builds the CR from target — this handler doesn't construct one. +func (cc *ControlCenter) handleIDPApplyForm(w http.ResponseWriter, r *http.Request, inst *Instance, target string) { + var body map[string]interface{} if err := json.NewDecoder(r.Body).Decode(&body); err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) fmt.Fprintf(w, `{"error":"invalid request body"}`) return } + // target is always the server-resolved value for this route, not + // whatever the client sent (there's no client-supplied target to trust + // here — the route itself determines which CRD this form is for). + body["target"] = target - // Namespace is never sent by the form — for a namespaced CRD it's - // resolved server-side by the Apply API from idp.namespace (which always - // wins over whatever a caller sends anyway); a cluster-scoped CRD has no - // namespace concept at all. See idp.namespace in the schema reference. - // - // body.Name is "" when idp.name is declared — the template omits the - // Name field entirely (see RequireIDPName), so there's nothing to collect. - // The Apply API resolves it server-side from idp.name the same way it - // resolves namespace. - metadata := map[string]any{ - "name": body.Name, - } - if len(body.Labels) > 0 { - metadata["labels"] = body.Labels - } - if len(body.Annotations) > 0 { - metadata["annotations"] = body.Annotations - } - cr := map[string]any{ - "apiVersion": apiVersion, - "kind": kind, - "metadata": metadata, - "spec": body.Spec, - } - crJSON, _ := json.Marshal(cr) + payload, _ := json.Marshal(body) applyURL := inst.GatewayEndpoint + "/api/v1/apply" if r.URL.Query().Get("dryRun") == "true" { applyURL += "?dryRun=true" } - cc.proxyIDPRequest(w, r, applyURL, http.MethodPost, bytes.NewReader(crJSON)) + cc.proxyIDPRequest(w, r, applyURL, http.MethodPost, bytes.NewReader(payload)) } // idpParseGVK splits "group/version, Kind=Kind" into (kind, apiVersion). diff --git a/cmd/controlcenter/cc/idp_fields_test.go b/cmd/controlcenter/cc/idp_fields_test.go index 35f134f3..f124e765 100644 --- a/cmd/controlcenter/cc/idp_fields_test.go +++ b/cmd/controlcenter/cc/idp_fields_test.go @@ -5,43 +5,23 @@ import ( "testing" ) -// Regression test for the bug where every CRD-schema enum field silently -// rendered as a plain text box instead of a dropdown: buildSpecIDPField's -// switch once required prop.Type == "enum" before treating a field as a -// select, but CRD OpenAPI schemas never set type: enum — enum is always a -// constraint alongside type: string. The check must depend only on -// len(prop.Enum) > 0. -func TestBuildSpecIDPField_SchemaEnumRendersAsSelect(t *testing.T) { - prop := idpSchemaProperty{ - Type: "string", - Enum: []string{"app", "cert", "monitoring", "infra"}, - } - f := buildSpecIDPField("workloadType", prop, idpFieldHint{}, false) - - if f.InputType != "select" { - t.Fatalf("InputType = %q, want %q (prop.Type=%q with Enum set must still render as a dropdown)", f.InputType, "select", prop.Type) - } - if len(f.Enum) != 4 || f.Enum[0] != "app" { - t.Fatalf("Enum = %v, want the schema's enum list carried through", f.Enum) - } -} - -func TestBuildSpecIDPField_InputTypes(t *testing.T) { +func TestBuildIDPField_InputTypes(t *testing.T) { cases := []struct { name string - prop idpSchemaProperty + hint idpFieldHint want string }{ - {"enum wins even with a base type", idpSchemaProperty{Type: "string", Enum: []string{"a", "b"}}, "select"}, - {"boolean", idpSchemaProperty{Type: "boolean"}, "checkbox"}, - {"integer", idpSchemaProperty{Type: "integer"}, "number"}, - {"number", idpSchemaProperty{Type: "number"}, "number"}, - {"plain string", idpSchemaProperty{Type: "string"}, "text"}, - {"unknown/empty type", idpSchemaProperty{}, "text"}, + {"enum with values", idpFieldHint{Type: "enum", Enum: []string{"a", "b"}}, "select"}, + {"enum type but no values falls through", idpFieldHint{Type: "enum"}, "text"}, + {"boolean", idpFieldHint{Type: "boolean"}, "checkbox"}, + {"integer", idpFieldHint{Type: "integer"}, "number"}, + {"number", idpFieldHint{Type: "number"}, "number"}, + {"string", idpFieldHint{Type: "string"}, "text"}, + {"type omitted defaults to text", idpFieldHint{}, "text"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { - f := buildSpecIDPField("field", c.prop, idpFieldHint{}, false) + f := buildIDPField("field", c.hint) if f.InputType != c.want { t.Errorf("InputType = %q, want %q", f.InputType, c.want) } @@ -49,68 +29,36 @@ func TestBuildSpecIDPField_InputTypes(t *testing.T) { } } -func TestBuildSpecIDPField_LabelFallback(t *testing.T) { - f := buildSpecIDPField("workloadType", idpSchemaProperty{}, idpFieldHint{}, false) +func TestBuildIDPField_LabelFallback(t *testing.T) { + f := buildIDPField("workloadType", idpFieldHint{}) if f.Label != "WorkloadType" { t.Errorf("Label = %q, want auto-capitalized field name %q", f.Label, "WorkloadType") } - f = buildSpecIDPField("workloadType", idpSchemaProperty{}, idpFieldHint{Label: "Workload Type"}, false) + f = buildIDPField("workloadType", idpFieldHint{Label: "Workload Type"}) if f.Label != "Workload Type" { t.Errorf("Label = %q, want hint.Label to override the fallback", f.Label) } } -func TestBuildSpecIDPField_HintFallsBackToSchemaDescription(t *testing.T) { - prop := idpSchemaProperty{Description: "Team that owns this resource"} - - f := buildSpecIDPField("team", prop, idpFieldHint{}, false) - if f.Hint != prop.Description { - t.Errorf("Hint = %q, want schema Description %q when hint.Hint is empty", f.Hint, prop.Description) - } - - f = buildSpecIDPField("team", prop, idpFieldHint{Hint: "custom hint"}, false) - if f.Hint != "custom hint" { - t.Errorf("Hint = %q, want hint.Hint to take precedence over schema Description", f.Hint) - } -} - -func TestBuildSpecIDPField_Required(t *testing.T) { - // required via the CRD schema's required: list - f := buildSpecIDPField("team", idpSchemaProperty{}, idpFieldHint{}, true) - if !f.Required { - t.Error("Required = false, want true when the schema marks the field required") - } - - // required via idp.fields..required, independent of the schema - f = buildSpecIDPField("team", idpSchemaProperty{}, idpFieldHint{Required: true}, false) +func TestBuildIDPField_Required(t *testing.T) { + f := buildIDPField("team", idpFieldHint{Required: true}) if !f.Required { t.Error("Required = false, want true when hint.Required is set") } - f = buildSpecIDPField("team", idpSchemaProperty{}, idpFieldHint{}, false) + f = buildIDPField("team", idpFieldHint{}) if f.Required { - t.Error("Required = true, want false when neither source marks it required") + t.Error("Required = true, want false when hint.Required is unset") } } -func TestBuildSpecIDPField_DefaultAndSource(t *testing.T) { - prop := idpSchemaProperty{Default: 3} - f := buildSpecIDPField("replicas", prop, idpFieldHint{}, false) - if f.Default != "3" { - t.Errorf("Default = %q, want %q", f.Default, "3") - } - if f.Source != IDPFieldSourceSpec { - t.Errorf("Source = %q, want %q", f.Source, IDPFieldSourceSpec) - } -} - -func TestBuildSpecIDPField_WhenAnyOfEncodedAsJSON(t *testing.T) { +func TestBuildIDPField_WhenAnyOfEncodedAsJSON(t *testing.T) { hint := idpFieldHint{ When: []json.RawMessage{json.RawMessage(`{"field":"spec.workloadType","equals":"app"}`)}, AnyOf: []json.RawMessage{json.RawMessage(`{"field":"spec.workloadType","equals":"cert"}`)}, } - f := buildSpecIDPField("repoURL", idpSchemaProperty{}, hint, false) + f := buildIDPField("repoURL", hint) var when []map[string]string if err := json.Unmarshal([]byte(f.WhenJSON), &when); err != nil { @@ -129,53 +77,8 @@ func TestBuildSpecIDPField_WhenAnyOfEncodedAsJSON(t *testing.T) { } } -func TestBuildAdditionalIDPField_InputTypes(t *testing.T) { - cases := []struct { - name string - hint idpFieldHint - want string - }{ - {"enum with values", idpFieldHint{Type: "enum", Enum: []string{"a", "b"}}, "select"}, - {"enum type but no values falls through", idpFieldHint{Type: "enum"}, "text"}, - {"boolean", idpFieldHint{Type: "boolean"}, "checkbox"}, - {"integer", idpFieldHint{Type: "integer"}, "number"}, - {"number", idpFieldHint{Type: "number"}, "number"}, - {"string", idpFieldHint{Type: "string"}, "text"}, - {"type omitted defaults to text", idpFieldHint{}, "text"}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - f := buildAdditionalIDPField("field", c.hint, IDPFieldSourceLabel) - if f.InputType != c.want { - t.Errorf("InputType = %q, want %q", f.InputType, c.want) - } - }) - } -} - -func TestBuildAdditionalIDPField_LabelFallbackAndSource(t *testing.T) { - f := buildAdditionalIDPField("team", idpFieldHint{}, IDPFieldSourceLabel) - if f.Label != "team" { - t.Errorf("Label = %q, want the raw key %q (additionalFields keys are not auto-capitalized)", f.Label, "team") - } - if f.Source != IDPFieldSourceLabel { - t.Errorf("Source = %q, want %q", f.Source, IDPFieldSourceLabel) - } - - f = buildAdditionalIDPField("platform.myorg.io/expose", idpFieldHint{Label: "Expose externally"}, IDPFieldSourceAnnotation) - if f.Label != "Expose externally" { - t.Errorf("Label = %q, want hint.Label to override the raw key", f.Label) - } - if f.Source != IDPFieldSourceAnnotation { - t.Errorf("Source = %q, want %q", f.Source, IDPFieldSourceAnnotation) - } -} - -func TestBuildAdditionalIDPField_RequiredCategoryDisabled(t *testing.T) { - f := buildAdditionalIDPField("team", idpFieldHint{Required: true, Category: "Ownership", Disabled: "locked for maintenance"}, IDPFieldSourceLabel) - if !f.Required { - t.Error("Required = false, want true") - } +func TestBuildIDPField_CategoryAndDisabled(t *testing.T) { + f := buildIDPField("team", idpFieldHint{Category: "Ownership", Disabled: "locked for maintenance"}) if f.Category != "Ownership" { t.Errorf("Category = %q, want %q", f.Category, "Ownership") } diff --git a/cmd/controlcenter/cc/types.go b/cmd/controlcenter/cc/types.go index 47c28066..2cc75ce9 100644 --- a/cmd/controlcenter/cc/types.go +++ b/cmd/controlcenter/cc/types.go @@ -218,11 +218,15 @@ type EndpointInfo struct { // CRDSummary is a summary of a CRD type CRDSummary struct { - Name string `json:"name"` - Description string `json:"description,omitempty"` - Mode string `json:"mode,omitempty"` - GVK string `json:"gvk,omitempty"` - GVR string `json:"gvr,omitempty"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Mode string `json:"mode,omitempty"` + GVK string `json:"gvk,omitempty"` + GVR string `json:"gvr,omitempty"` + // Target is the identifier this CRD is addressed by in the Apply API and + // schema API (idp.target, or the lowercased kind when unset). Empty when + // IDP isn't enabled for this CRD. + Target string `json:"target,omitempty"` Namespaced bool `json:"namespaced"` Namespace string `json:"namespace,omitempty"` CrossAccess bool `json:"crossAccess"` @@ -249,15 +253,6 @@ type CRDSummary struct { RequireIDPName bool `json:"requireIdpName,omitempty"` } -// IDP field write targets — which part of the CR an IDPField's submitted -// value gets written to. Mirrors the bucketing in handleIDPApplyForm and the -// data-source attribute idp_form.html's collectPayload() reads client-side. -const ( - IDPFieldSourceSpec = "spec" - IDPFieldSourceLabel = "label" - IDPFieldSourceAnnotation = "annotation" -) - // IDPField is one rendered field in the IDP create form. type IDPField struct { Name string @@ -267,12 +262,10 @@ type IDPField struct { Hint string Enum []string Required bool - Default string // pre-populated value from CRD schema default: Category string // section heading for visual grouping WhenJSON string // JSON array of Condition — all must be true (AND) AnyOfJSON string // JSON array of Condition — at least one must be true (OR) Disabled string // non-empty → greyed-out field with this message - Source string // IDPFieldSourceSpec (default) | IDPFieldSourceLabel | IDPFieldSourceAnnotation } // IDPSection is a group of fields sharing a section heading in the IDP form. @@ -283,8 +276,13 @@ type IDPSection struct { // IDPFormData is the view model for idp_form.html. type IDPFormData struct { - KatalogName string - CRDName string + KatalogName string + CRDName string + // Target is the identifier submitted to the gateway's Apply API + // (idp.target, or the lowercased kind when unset). + Target string + // Kind/APIVersion are display-only — shown in the page header, not used + // to build the submitted payload (the gateway builds the CR). Kind string APIVersion string BackURL string diff --git a/cmd/controlcenter/docs/02-routing.md b/cmd/controlcenter/docs/02-routing.md index a11a211c..395f0e51 100644 --- a/cmd/controlcenter/docs/02-routing.md +++ b/cmd/controlcenter/docs/02-routing.md @@ -21,13 +21,14 @@ All requests arrive at `ControlCenter.ServeHTTP`, which strips the `/controlcent | `/katalog/{name}/crd/{crd}/raw` | `handleProxyCRDSpec` | — (JSON proxy) | | `/katalog/{name}/crd/{crd}/enriched` | `handleProxyCRDSpec` | — (JSON proxy) | | `/katalog/{name}/crd/{crd}/cr` | `handleCRList` | `cr_list.html` | +| `/katalog/{name}/crd/{crd}/cr/create` | `handleIDPCreateForm` (GET renders, POST applies) | `idp_form.html` | | `/katalog/{name}/crd/{crd}/cr/{crname}` | `handleCRDetail` (cluster-scoped) | `cr_detail.html` | | `/katalog/{name}/crd/{crd}/cr/{ns}/{crname}` | `handleCRDetail` (namespaced) | `cr_detail.html` | | `GET /api/instances` | `handleListInstances` | — (JSON) | | `POST /api/instances` | `handleAddInstance` | — (JSON) | | `PUT /api/instances/{url}` | `handleUpdateInstance` | — (JSON) | | `DELETE /api/instances/{url}` | `handleDeleteInstance` | — (JSON) | -| `GET /api/idp/schema/{kind}` | `handleIDPSchema` | — (JSON proxy) | +| `GET /api/idp/schema/{target}` | `handleIDPSchema` | — (JSON proxy) | | `POST /api/idp/apply` | `handleIDPApply` | — (JSON proxy) | ## Katalog sub-routing @@ -56,9 +57,9 @@ len(crParts) == 3 → namespaced CR detail (/cr/{ns}/{name}) ## IDP proxy endpoints -`/api/idp/schema/{kind}` and `/api/idp/apply` are server-side proxies to the companion gateway's Apply API. The CC holds the `GATEWAY_TOKEN` bearer token; the browser never sees it. All IDP form traffic flows through these two routes — schema fetch and CR apply — so the gateway can be behind a different origin without CORS or token-exposure issues. +`/api/idp/schema/{target}` and `/api/idp/apply` are standalone server-side proxies to the companion gateway's Apply API, for callers that want the gateway's raw JSON directly rather than the rendered form. The CC holds the `GATEWAY_TOKEN` bearer token; the browser never sees it. The `[+ Create]` form itself doesn't use these — see [06-idp-form.md](06-idp-form.md) for its actual request flow (`handleIDPCreateForm`/`fetchIDPFields`/`handleIDPApplyForm`). -`handleIDPSchema` forwards `GET {gatewayEndpoint}/api/v1/schema/{kind}`. `handleIDPApply` forwards `POST {gatewayEndpoint}/api/v1/apply` with the request body unchanged. Both respond with the gateway's status code and JSON body verbatim. +`handleIDPSchema` forwards `GET {gatewayEndpoint}/api/v1/schema?target={target}`. `handleIDPApply` forwards `POST {gatewayEndpoint}/api/v1/apply` with the request body unchanged. Both respond with the gateway's status code and JSON body verbatim. ## Template rendering diff --git a/cmd/controlcenter/docs/03-data-flow.md b/cmd/controlcenter/docs/03-data-flow.md index cd8657f5..d423c26a 100644 --- a/cmd/controlcenter/docs/03-data-flow.md +++ b/cmd/controlcenter/docs/03-data-flow.md @@ -26,19 +26,20 @@ When a runtime advertises a companion gateway via `"gatewayEndpoint"` in its `/k | Endpoint | Go type | Used by | |----------|---------|---------| | `GET /katalog/{crd}` | `GatewayCRDStats` | `handleCRDDetail` — merges admission, conversion, deletion/namespace protection stats | -| `GET /api/v1/schema/{kind}` | raw JSON | `handleIDPSchema` — proxied to browser for IDP form rendering | -| `POST /api/v1/apply` | raw JSON | `handleIDPApply` — proxied from IDP form submit | +| `GET /api/v1/schema?target={target}` | `SchemaResponse` (flat field map) | `fetchIDPFields` (form render); `handleIDPSchema` proxy | +| `POST /api/v1/apply` | `ApplyResponse` | `handleIDPApplyForm` (form submit); `handleIDPApply` proxy | -The gateway URL is stored on `Instance.GatewayEndpoint` when the runtime katalog is fetched. Webhook stats are queried on-demand at CRD detail page load. IDP schema and apply calls are proxied on-demand from the browser via the CC's `/api/idp/` routes — the CC adds the `Authorization: Bearer` header so the token stays server-side. +The gateway URL is stored on `Instance.GatewayEndpoint` when the runtime katalog is fetched. Webhook stats are queried on-demand at CRD detail page load. Schema fetch and apply for the `[+ Create]` form happen server-side in `handleIDPCreateForm`/`handleIDPApplyForm` — the CC adds the `Authorization: Bearer` header so the token stays server-side. The `/api/idp/*` routes are a separate, standalone proxy pair for callers that want raw gateway JSON directly (see [02-routing.md](02-routing.md)). ## IDP mode -When the runtime includes `idpEnabled: true` on a `CRDSummary` entry in the `/katalog` response, the CR list page for that CRD renders a `[+ Create]` button. Clicking it fetches the CRD schema from the gateway (via `/api/idp/schema/{kind}`) and renders a form. On submit the CC posts to `/api/idp/apply` which forwards to the gateway's `POST /api/v1/apply`. +When the runtime includes `"idpEnabled": true` and a non-empty `"target"` on a `CRDSummaryResponse` entry in the `/katalog` response, the CR list page for that CRD renders a `[+ Create]` button linking to `/katalog/{kat}/crd/{crd}/cr/create`. That route fetches the flat field schema for `Target` (`GET /api/v1/schema?target=...`) and renders `idp_form.html`. On submit, the browser POSTs a flat `{"target": "...", ...fields}` object back to the same route; `handleIDPApplyForm` forwards it to the gateway's `POST /api/v1/apply`, which builds the CR. Control Center never constructs a CR itself — see [06-idp-form.md](06-idp-form.md). The `GATEWAY_TOKEN` env var on the CC is the bearer token sent to the gateway. The browser only ever talks to the CC — no cross-origin requests, no token exposure. ``` KatalogResponse.CRDs[].IdpEnabled ← per-CRD flag from runtime /katalog +KatalogResponse.CRDs[].Target ← idp.target (or lowercased kind); empty when IDP disabled KatalogResponse.GatewayEndpoint ← stored on Instance; used by CC proxy handlers ControlCenter.gatewayToken ← from GATEWAY_TOKEN env var; never sent to browser ``` diff --git a/cmd/controlcenter/docs/06-idp-form.md b/cmd/controlcenter/docs/06-idp-form.md index 3a28745c..4bac19dc 100644 --- a/cmd/controlcenter/docs/06-idp-form.md +++ b/cmd/controlcenter/docs/06-idp-form.md @@ -1,6 +1,8 @@ # 06 — IDP Self-Service Form -The IDP form is the browser-native delivery path for the Apply API. A platform team declares `idp.enabled: true` on a CRD entry; from that moment, any developer with access to the Control Center can create instances of that CRD by filling a form — no YAML, no `kubectl`, no cluster credentials. +The IDP form is the browser-native delivery path for the Apply API's target mode. A platform team declares `idp.enabled: true` and `idp.fields`/`idp.additionalFields` on a CRD entry; from that moment, any developer with access to the Control Center can create instances of that CRD by filling a form — no YAML, no `kubectl`, no cluster credentials, and no knowledge of the CRD's Kubernetes shape. + +Control Center never constructs a Kubernetes CR itself. It submits a flat `{"target": "", ...fields}` payload to the gateway's Apply API; the gateway (`pkg/gateway/applyapi`) resolves the target to a CRD and builds the CR via `BuildCRFromTarget`, using the CRD's `idp.fields`/`idp.additionalFields` declarations to route each field into `spec`, `metadata.labels`, or `metadata.annotations`, and `idp.name`/`idp.namespace` to resolve identity. ## Activation @@ -8,91 +10,80 @@ Three things must all be true for the `[+ Create]` button to appear: 1. `gateway.applyAPI.enabled: true` on the Katalog 2. `idp.enabled: true` on the CRD entry -3. `ORK_CC_APPLY_TOKEN` set on the CC process +3. `GATEWAY_TOKEN` set on the CC process -The runtime sets `CRDSummary.IDPEnabled` in its `/katalog` response (field: `"idpEnabled"`). The CC reads it during the background fetch and passes it to `CRListView.IDPEnabled`. The button is suppressed server-side when no Apply token is configured so the route is never advertised without auth. +The runtime sets `CRDSummaryResponse.IDPEnabled` and `CRDSummaryResponse.Target` in its `/katalog` response (`"idpEnabled"`, `"target"`). CC mirrors both onto `CRDSummary`. `Target` is the identifier CC submits to the gateway — it comes from `idp.target` if the platform team set one, otherwise the lowercased Kind — CC never derives it itself. `handleIDPCreateForm` redirects back if `IdpEnabled` is false or `Target` is empty (IDP not actually usable for this CRD). ## Request flow ``` -Browser GET /controlcenter/katalog/{kat}/crd/{crd}/idp +Browser GET /controlcenter/katalog/{kat}/crd/{crd}/cr/create │ - ├─ handleIDPCreate reads Instance.GatewayEndpoint - ├─ FetchIDPSchema → GET {gateway}/api/v1/schema/{kind} - │ Authorization: Bearer {ORK_CC_APPLY_TOKEN} - │ ← SchemaResponse { kind, apiVersion, properties, idpFields } + ├─ handleIDPCreateForm looks up CRDSummary.Target for this CRD + ├─ fetchIDPFields → GET {gateway}/api/v1/schema?target={target} + │ Authorization: Bearer {GATEWAY_TOKEN} + │ ← SchemaResponse { target, title, description, fields, required } + │ fields is a FLAT map[string]IDPFieldConfig — no spec/label/ + │ annotation distinction. The gateway resolves that at apply time + │ from the same idp.fields/idp.additionalFields declaration. │ - └─ renderTemplate("idp_form.html", IDPFormView) - Fields assembled by buildIDPFormFields: - OpenAPI property type → HTML input type - string → - string + enum → - boolean → - IDP hint.label →