From 91583a4e1a11aebd545ef0457b514ea6f73c6d21 Mon Sep 17 00:00:00 2001 From: Andrei Kvapil Date: Wed, 9 Jul 2025 18:28:06 +0200 Subject: [PATCH] [cozystack-api] Refactor OpenAPI Schema Signed-off-by: Andrei Kvapil --- pkg/cmd/server/openapi.go | 201 ++++++++++++++++++++++++++++++++++++++ pkg/cmd/server/start.go | 133 +++++-------------------- pkg/config/config.go | 9 +- 3 files changed, 228 insertions(+), 115 deletions(-) create mode 100644 pkg/cmd/server/openapi.go diff --git a/pkg/cmd/server/openapi.go b/pkg/cmd/server/openapi.go new file mode 100644 index 00000000..94db59f8 --- /dev/null +++ b/pkg/cmd/server/openapi.go @@ -0,0 +1,201 @@ +package server + +import ( + "encoding/json" + "fmt" + "strings" + + "k8s.io/kube-openapi/pkg/spec3" + "k8s.io/kube-openapi/pkg/validation/spec" +) + +// ----------------------------------------------------------------------------- +// shared helpers +// ----------------------------------------------------------------------------- + +const ( + baseRef = "com.github.cozystack.cozystack.pkg.apis.apps.v1alpha1.Application" + baseListRef = baseRef + "List" +) + +func deepCopySchema(in *spec.Schema) *spec.Schema { + if in == nil { + return nil + } + b, err := json.Marshal(in) + if err != nil { + // Log error or panic since this is unexpected + panic(fmt.Sprintf("failed to marshal schema: %v", err)) + } + var out spec.Schema + if err := json.Unmarshal(b, &out); err != nil { + panic(fmt.Sprintf("failed to unmarshal schema: %v", err)) + } + return &out +} + +// find the object that already owns ".spec" +func findSpecContainer(s *spec.Schema) *spec.Schema { + if s == nil { + return nil + } + if len(s.Type) > 0 && s.Type.Contains("object") && s.Properties != nil { + if _, ok := s.Properties["spec"]; ok { + return s + } + } + for _, branch := range [][]spec.Schema{s.AllOf, s.OneOf, s.AnyOf} { + for i := range branch { + if res := findSpecContainer(&branch[i]); res != nil { + return res + } + } + } + return nil +} + +// apply user-supplied schema; when raw == "" turn the field into a schemaless object +func patchSpec(target *spec.Schema, raw string) error { + // ------------------------------------------------------------------ + // 1) schema not provided → make ".spec" a fully open object + // ------------------------------------------------------------------ + if strings.TrimSpace(raw) == "" { + if target.Properties == nil { + target.Properties = map[string]spec.Schema{} + } + prop := target.Properties["spec"] + prop.AdditionalProperties = &spec.SchemaOrBool{ + Allows: true, + Schema: &spec.Schema{}, + } + target.Properties["spec"] = prop + return nil + } + + // ------------------------------------------------------------------ + // 2) custom schema provided → keep / inject additionalProperties + // ------------------------------------------------------------------ + var custom spec.Schema + if err := json.Unmarshal([]byte(raw), &custom); err != nil { + return err + } + + // if user didn't specify additionalProperties, add a permissive one + if custom.AdditionalProperties == nil { + custom.AdditionalProperties = &spec.SchemaOrBool{ + Allows: true, + Schema: &spec.Schema{}, + } + } + + if target.Properties == nil { + target.Properties = map[string]spec.Schema{} + } + target.Properties["spec"] = custom + return nil +} + +// ----------------------------------------------------------------------------- +// OpenAPI **v3** post-processor +// ----------------------------------------------------------------------------- +func buildPostProcessV3(kindSchemas map[string]string) func(*spec3.OpenAPI) (*spec3.OpenAPI, error) { + + return func(doc *spec3.OpenAPI) (*spec3.OpenAPI, error) { + if doc.Components == nil { + doc.Components = &spec3.Components{} + } + if doc.Components.Schemas == nil { + doc.Components.Schemas = map[string]*spec.Schema{} + } + + base, ok := doc.Components.Schemas[baseRef] + if !ok { + return doc, fmt.Errorf("base schema %q not found", baseRef) + } + + for kind, raw := range kindSchemas { + ref := fmt.Sprintf("%s.%s", "com.github.cozystack.cozystack.pkg.apis.apps.v1alpha1", kind) + + s := doc.Components.Schemas[ref] + if s == nil { // first time – clone "Application" + s = deepCopySchema(base) + s.Extensions = map[string]interface{}{ + "x-kubernetes-group-version-kind": []interface{}{ + map[string]interface{}{ + "group": "apps.cozystack.io", "version": "v1alpha1", "kind": kind, + }, + }, + } + doc.Components.Schemas[ref] = s + } + + container := findSpecContainer(s) + if container == nil { // fallback: use the root + container = s + } + if err := patchSpec(container, raw); err != nil { + return nil, fmt.Errorf("kind %s: %w", kind, err) + } + } + + delete(doc.Components.Schemas, baseRef) + delete(doc.Components.Schemas, baseListRef) + return doc, nil + } +} + +// ----------------------------------------------------------------------------- +// OpenAPI **v2** (swagger) post-processor +// ----------------------------------------------------------------------------- +func buildPostProcessV2(kindSchemas map[string]string) func(*spec.Swagger) (*spec.Swagger, error) { + + return func(sw *spec.Swagger) (*spec.Swagger, error) { + defs := sw.Definitions + base, ok := defs[baseRef] + if !ok { + return sw, fmt.Errorf("base schema %q not found", baseRef) + } + + for kind, raw := range kindSchemas { + ref := fmt.Sprintf("%s.%s", "com.github.cozystack.cozystack.pkg.apis.apps.v1alpha1", kind) + + s := deepCopySchema(&base) + s.Extensions = map[string]interface{}{ + "x-kubernetes-group-version-kind": []interface{}{ + map[string]interface{}{ + "group": "apps.cozystack.io", "version": "v1alpha1", "kind": kind, + }, + }, + } + + if err := patchSpec(s, raw); err != nil { + return nil, fmt.Errorf("kind %s: %w", kind, err) + } + + defs[ref] = *s + + // clone the List variant + listName := ref + "List" + listSrc := defs[baseListRef] + listCopy := deepCopySchema(&listSrc) + listCopy.Extensions = map[string]interface{}{ + "x-kubernetes-group-version-kind": []interface{}{ + map[string]interface{}{ + "group": "apps.cozystack.io", + "version": "v1alpha1", + "kind": kind + "List", + }, + }, + } + if items := listCopy.Properties["items"]; items.Items != nil && items.Items.Schema != nil { + items.Items.Schema.Ref = spec.MustCreateRef("#/definitions/" + ref) + listCopy.Properties["items"] = items + } + defs[listName] = *listCopy + } + + delete(defs, baseRef) + delete(defs, baseListRef) + return sw, nil + } +} diff --git a/pkg/cmd/server/start.go b/pkg/cmd/server/start.go index 8121ffdf..6f0cfac4 100644 --- a/pkg/cmd/server/start.go +++ b/pkg/cmd/server/start.go @@ -18,6 +18,8 @@ package server import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "io" @@ -38,8 +40,6 @@ import ( utilversionpkg "k8s.io/apiserver/pkg/util/version" "k8s.io/component-base/featuregate" baseversion "k8s.io/component-base/version" - "k8s.io/klog/v2" - "k8s.io/kube-openapi/pkg/validation/spec" netutils "k8s.io/utils/net" ) @@ -159,22 +159,6 @@ func (o AppsServerOptions) Validate(args []string) error { return utilerrors.NewAggregate(allErrors) } -// DeepCopySchema делает глубокую копию структуры spec.Schema -func DeepCopySchema(schema *spec.Schema) (*spec.Schema, error) { - data, err := json.Marshal(schema) - if err != nil { - return nil, fmt.Errorf("failed to marshal schema: %w", err) - } - - var newSchema spec.Schema - err = json.Unmarshal(data, &newSchema) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal schema: %w", err) - } - - return &newSchema, nil -} - // Config returns the configuration for the API server based on AppsServerOptions func (o *AppsServerOptions) Config() (*apiserver.Config, error) { // TODO: set the "real" external address @@ -195,107 +179,34 @@ func (o *AppsServerOptions) Config() (*apiserver.Config, error) { serverConfig.OpenAPIConfig = genericapiserver.DefaultOpenAPIConfig( sampleopenapi.GetOpenAPIDefinitions, openapi.NewDefinitionNamer(apiserver.Scheme), ) - serverConfig.OpenAPIConfig.Info.Title = "Apps" - serverConfig.OpenAPIConfig.Info.Version = "0.1" - serverConfig.OpenAPIConfig.PostProcessSpec = func(swagger *spec.Swagger) (*spec.Swagger, error) { - defs := swagger.Definitions - - // Verify the presence of the base Application/ApplicationList definitions - appDef, exists := defs["com.github.cozystack.cozystack.pkg.apis.apps.v1alpha1.Application"] - if !exists { - return swagger, fmt.Errorf("Application definition not found") + version := "0.1" + if o.ResourceConfig != nil { + raw, err := json.Marshal(o.ResourceConfig) + if err != nil { + return nil, fmt.Errorf("failed to marshal resource config: %v", err) } - - listDef, exists := defs["com.github.cozystack.cozystack.pkg.apis.apps.v1alpha1.ApplicationList"] - if !exists { - return swagger, fmt.Errorf("ApplicationList definition not found") - } - - // Iterate over all registered GVKs (e.g., Bucket, Database, etc.) - for _, gvk := range v1alpha1.RegisteredGVKs { - // This will be something like: - // "com.github.cozystack.cozystack.pkg.apis.apps.v1alpha1.Bucket" - resourceName := fmt.Sprintf("com.github.cozystack.cozystack.pkg.apis.apps.v1alpha1.%s", gvk.Kind) - - // 1. Create a copy of the base Application definition for the new resource - newDef, err := DeepCopySchema(&appDef) - if err != nil { - return nil, fmt.Errorf("failed to deepcopy schema for %s: %w", gvk.Kind, err) - } - - // 2. Update x-kubernetes-group-version-kind to match the new resource - if newDef.Extensions == nil { - newDef.Extensions = map[string]interface{}{} - } - newDef.Extensions["x-kubernetes-group-version-kind"] = []map[string]interface{}{ - { - "group": gvk.Group, - "version": gvk.Version, - "kind": gvk.Kind, - }, - } - - // make `.spec` schemaless so any keys are accepted - if specProp, ok := newDef.Properties["spec"]; ok { - specProp.AdditionalProperties = &spec.SchemaOrBool{ - Allows: true, - Schema: &spec.Schema{}, - } - newDef.Properties["spec"] = specProp - } - - // 3. Save the new resource definition under the correct name - defs[resourceName] = *newDef - klog.V(6).Infof("PostProcessSpec: Added OpenAPI definition for %s\n", resourceName) - - // 4. Now handle the corresponding List type (e.g., BucketList). - // We'll start by copying the ApplicationList definition. - listResourceName := fmt.Sprintf("com.github.cozystack.cozystack.pkg.apis.apps.v1alpha1.%sList", gvk.Kind) - newListDef, err := DeepCopySchema(&listDef) - if err != nil { - return nil, fmt.Errorf("failed to deepcopy schema for %sList: %w", gvk.Kind, err) - } - - // 5. Update x-kubernetes-group-version-kind for the List definition - if newListDef.Extensions == nil { - newListDef.Extensions = map[string]interface{}{} - } - newListDef.Extensions["x-kubernetes-group-version-kind"] = []map[string]interface{}{ - { - "group": gvk.Group, - "version": gvk.Version, - "kind": fmt.Sprintf("%sList", gvk.Kind), - }, - } - - // 6. IMPORTANT: Fix the "items" reference so it points to the new resource - // rather than to "Application". - if itemsProp, found := newListDef.Properties["items"]; found { - if itemsProp.Items != nil && itemsProp.Items.Schema != nil { - itemsProp.Items.Schema.Ref = spec.MustCreateRef("#/definitions/" + resourceName) - newListDef.Properties["items"] = itemsProp - } - } - - // 7. Finally, save the new List definition - defs[listResourceName] = *newListDef - klog.V(6).Infof("PostProcessSpec: Added OpenAPI definition for %s\n", listResourceName) - } - - // Remove the original Application/ApplicationList from the definitions - delete(defs, "com.github.cozystack.cozystack.pkg.apis.apps.v1alpha1.Application") - delete(defs, "com.github.cozystack.cozystack.pkg.apis.apps.v1alpha1.ApplicationList") - - swagger.Definitions = defs - return swagger, nil + sum := sha256.Sum256(raw) + version = "0.1-" + hex.EncodeToString(sum[:8]) } + // capture schemas from config once for fast lookup inside the closure + kindSchemas := map[string]string{} + for _, r := range o.ResourceConfig.Resources { + kindSchemas[r.Application.Kind] = r.Application.OpenAPISchema + } + + serverConfig.OpenAPIConfig.Info.Title = "Apps" + serverConfig.OpenAPIConfig.Info.Version = version + serverConfig.OpenAPIConfig.PostProcessSpec = buildPostProcessV2(kindSchemas) + serverConfig.OpenAPIV3Config = genericapiserver.DefaultOpenAPIV3Config( sampleopenapi.GetOpenAPIDefinitions, openapi.NewDefinitionNamer(apiserver.Scheme), ) serverConfig.OpenAPIV3Config.Info.Title = "Apps" - serverConfig.OpenAPIV3Config.Info.Version = "0.1" + serverConfig.OpenAPIV3Config.Info.Version = version + + serverConfig.OpenAPIV3Config.PostProcessSpec = buildPostProcessV3(kindSchemas) serverConfig.FeatureGate = utilversionpkg.DefaultComponentGlobalsRegistry.FeatureGateFor( utilversionpkg.DefaultKubeComponent, diff --git a/pkg/config/config.go b/pkg/config/config.go index 1317b8c2..6ada99d3 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -36,10 +36,11 @@ type Resource struct { // ApplicationConfig contains the application settings. type ApplicationConfig struct { - Kind string `yaml:"kind"` - Singular string `yaml:"singular"` - Plural string `yaml:"plural"` - ShortNames []string `yaml:"shortNames"` + Kind string `yaml:"kind"` + Singular string `yaml:"singular"` + Plural string `yaml:"plural"` + ShortNames []string `yaml:"shortNames"` + OpenAPISchema string `yaml:"openAPISchema"` } // ReleaseConfig contains the release settings.