From 9ef0e2ceeb8ac13cdf9e54fe5e373080120a790c Mon Sep 17 00:00:00 2001 From: Myasnikov Daniil Date: Mon, 16 Mar 2026 14:12:43 +0500 Subject: [PATCH] [docs] Added openapi generation tool Signed-off-by: Myasnikov Daniil --- Makefile | 6 +- hack/upload-assets.sh | 1 + pkg/apiserver/apiserver.go | 12 +- pkg/cmd/server/openapi.go | 40 +++++- pkg/cmd/server/start.go | 25 +--- tools/openapi-gen/main.go | 247 +++++++++++++++++++++++++++++++++++++ 6 files changed, 302 insertions(+), 29 deletions(-) create mode 100644 tools/openapi-gen/main.go diff --git a/Makefile b/Makefile index ca48197c..ce6eb7b3 100644 --- a/Makefile +++ b/Makefile @@ -58,7 +58,11 @@ manifests: cozypkg: go build -ldflags "-X github.com/cozystack/cozystack/cmd/cozypkg/cmd.Version=v$(COZYSTACK_VERSION)" -o _out/bin/cozypkg ./cmd/cozypkg -assets: assets-talos assets-cozypkg +assets: assets-talos assets-cozypkg openapi-json + +openapi-json: + mkdir -p _out/assets + VERSION=$(shell git describe --tags --always 2>/dev/null || echo dev) go run ./tools/openapi-gen/ 2>/dev/null > _out/assets/openapi.json assets-talos: make -C packages/core/talos assets diff --git a/hack/upload-assets.sh b/hack/upload-assets.sh index f9bb7b25..2f7f9813 100755 --- a/hack/upload-assets.sh +++ b/hack/upload-assets.sh @@ -14,3 +14,4 @@ gh release upload --clobber $version _out/assets/kernel-amd64 gh release upload --clobber $version _out/assets/initramfs-metal-amd64.xz gh release upload --clobber $version _out/assets/cozypkg-*.tar.gz gh release upload --clobber $version _out/assets/cozypkg-checksums.txt +gh release upload --clobber $version _out/assets/openapi.json diff --git a/pkg/apiserver/apiserver.go b/pkg/apiserver/apiserver.go index bc2b4857..bf02e642 100644 --- a/pkg/apiserver/apiserver.go +++ b/pkg/apiserver/apiserver.go @@ -211,15 +211,21 @@ func (c completedConfig) New() (*CozyServer, error) { storage := applicationstorage.NewREST(cli, watchCli, &resConfig) appsV1alpha1Storage[resConfig.Application.Plural] = cozyregistry.RESTInPeace(storage) } - appsApiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(apps.GroupName, Scheme, metav1.ParameterCodec, Codecs) - appsApiGroupInfo.VersionedResourcesStorageMap["v1alpha1"] = appsV1alpha1Storage - if err := s.GenericAPIServer.InstallAPIGroup(&appsApiGroupInfo); err != nil { + if err := InstallAppsAPIGroup(s.GenericAPIServer, appsV1alpha1Storage); err != nil { return nil, err } return s, nil } +// InstallAppsAPIGroup registers the apps.cozystack.io API group on the given +// server using the provided storage map (plural name → rest.Storage). +func InstallAppsAPIGroup(server *genericapiserver.GenericAPIServer, storage map[string]rest.Storage) error { + info := genericapiserver.NewDefaultAPIGroupInfo(apps.GroupName, Scheme, metav1.ParameterCodec, Codecs) + info.VersionedResourcesStorageMap["v1alpha1"] = storage + return server.InstallAPIGroup(&info) +} + func mustGetInformers(ctx context.Context, mgr ctrl.Manager, types ...client.Object) error { for i := range types { if _, err := mgr.GetCache().GetInformer(ctx, types[i]); err != nil { diff --git a/pkg/cmd/server/openapi.go b/pkg/cmd/server/openapi.go index 5d1b35db..2742f346 100644 --- a/pkg/cmd/server/openapi.go +++ b/pkg/cmd/server/openapi.go @@ -5,6 +5,11 @@ import ( "fmt" "strings" + "github.com/cozystack/cozystack/pkg/apiserver" + "github.com/cozystack/cozystack/pkg/config" + sampleopenapi "github.com/cozystack/cozystack/pkg/generated/openapi" + "k8s.io/apiserver/pkg/endpoints/openapi" + genericapiserver "k8s.io/apiserver/pkg/server" "k8s.io/kube-openapi/pkg/spec3" "k8s.io/kube-openapi/pkg/validation/spec" ) @@ -210,7 +215,9 @@ func rewriteRefForKind(old, kind string) string { // ----------------------------------------------------------------------------- // OpenAPI **v3** post-processor // ----------------------------------------------------------------------------- -func buildPostProcessV3(kindSchemas map[string]string) func(*spec3.OpenAPI) (*spec3.OpenAPI, error) { +// BuildPostProcessV3 returns an OpenAPI v3 post-processor that clones base +// Application schemas into per-kind schemas and rewrites $ref pointers. +func BuildPostProcessV3(kindSchemas map[string]string) func(*spec3.OpenAPI) (*spec3.OpenAPI, error) { return func(doc *spec3.OpenAPI) (*spec3.OpenAPI, error) { if doc.Components == nil { @@ -333,7 +340,36 @@ func sanitizeForV2(s *spec.Schema) { // ----------------------------------------------------------------------------- // OpenAPI **v2** (swagger) post-processor // ----------------------------------------------------------------------------- -func buildPostProcessV2(kindSchemas map[string]string) func(*spec.Swagger) (*spec.Swagger, error) { +// BuildPostProcessV2 returns a Swagger post-processor that clones base +// Application schemas into per-kind schemas and rewrites $ref pointers. +// KindSchemasFromConfig extracts the kind→OpenAPISchema mapping from a ResourceConfig. +func KindSchemasFromConfig(rc *config.ResourceConfig) map[string]string { + m := make(map[string]string, len(rc.Resources)) + for _, r := range rc.Resources { + m[r.Application.Kind] = r.Application.OpenAPISchema + } + return m +} + +// ConfigureOpenAPI sets up OpenAPI v2 and v3 on a GenericAPIServer Config, +// including the post-processors that clone Application schemas to per-kind schemas. +func ConfigureOpenAPI(cfg *genericapiserver.Config, kindSchemas map[string]string, title, version string) { + cfg.OpenAPIConfig = genericapiserver.DefaultOpenAPIConfig( + sampleopenapi.GetOpenAPIDefinitions, openapi.NewDefinitionNamer(apiserver.Scheme), + ) + cfg.OpenAPIConfig.Info.Title = title + cfg.OpenAPIConfig.Info.Version = version + cfg.OpenAPIConfig.PostProcessSpec = BuildPostProcessV2(kindSchemas) + + cfg.OpenAPIV3Config = genericapiserver.DefaultOpenAPIV3Config( + sampleopenapi.GetOpenAPIDefinitions, openapi.NewDefinitionNamer(apiserver.Scheme), + ) + cfg.OpenAPIV3Config.Info.Title = title + cfg.OpenAPIV3Config.Info.Version = version + cfg.OpenAPIV3Config.PostProcessSpec = BuildPostProcessV3(kindSchemas) +} + +func BuildPostProcessV2(kindSchemas map[string]string) func(*spec.Swagger) (*spec.Swagger, error) { return func(sw *spec.Swagger) (*spec.Swagger, error) { defs := sw.Definitions base, ok1 := defs[baseRef] diff --git a/pkg/cmd/server/start.go b/pkg/cmd/server/start.go index 9a688700..f7791332 100644 --- a/pkg/cmd/server/start.go +++ b/pkg/cmd/server/start.go @@ -31,12 +31,10 @@ import ( corev1alpha1 "github.com/cozystack/cozystack/pkg/apis/core/v1alpha1" "github.com/cozystack/cozystack/pkg/apiserver" "github.com/cozystack/cozystack/pkg/config" - sampleopenapi "github.com/cozystack/cozystack/pkg/generated/openapi" "github.com/spf13/cobra" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime" utilerrors "k8s.io/apimachinery/pkg/util/errors" - "k8s.io/apiserver/pkg/endpoints/openapi" genericapiserver "k8s.io/apiserver/pkg/server" genericoptions "k8s.io/apiserver/pkg/server/options" utilfeature "k8s.io/apiserver/pkg/util/feature" @@ -213,10 +211,6 @@ func (o *CozyServerOptions) Config() (*apiserver.Config, error) { serverConfig := genericapiserver.NewRecommendedConfig(apiserver.Codecs) - serverConfig.OpenAPIConfig = genericapiserver.DefaultOpenAPIConfig( - sampleopenapi.GetOpenAPIDefinitions, openapi.NewDefinitionNamer(apiserver.Scheme), - ) - apiVersion := "0.1" if o.ResourceConfig != nil { raw, err := json.Marshal(o.ResourceConfig) @@ -227,23 +221,8 @@ func (o *CozyServerOptions) Config() (*apiserver.Config, error) { apiVersion = "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 = "Cozy" - serverConfig.OpenAPIConfig.Info.Version = apiVersion - serverConfig.OpenAPIConfig.PostProcessSpec = buildPostProcessV2(kindSchemas) - - serverConfig.OpenAPIV3Config = genericapiserver.DefaultOpenAPIV3Config( - sampleopenapi.GetOpenAPIDefinitions, openapi.NewDefinitionNamer(apiserver.Scheme), - ) - serverConfig.OpenAPIV3Config.Info.Title = "Cozy" - serverConfig.OpenAPIV3Config.Info.Version = apiVersion - - serverConfig.OpenAPIV3Config.PostProcessSpec = buildPostProcessV3(kindSchemas) + kindSchemas := KindSchemasFromConfig(o.ResourceConfig) + ConfigureOpenAPI(&serverConfig.Config, kindSchemas, "Cozy", apiVersion) // Set FeatureGate and EffectiveVersion - required for Complete() in Kubernetes v0.34.1 // Following the pattern from sample-apiserver, but creating EffectiveVersion directly diff --git a/tools/openapi-gen/main.go b/tools/openapi-gen/main.go new file mode 100644 index 00000000..74b23b2b --- /dev/null +++ b/tools/openapi-gen/main.go @@ -0,0 +1,247 @@ +// Copyright 2024 The Cozystack Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Command openapi-gen assembles the OpenAPI v3 spec for apps.cozystack.io by +// spinning up an in-process Kubernetes API server with stub storage, so the +// framework generates the exact same paths and schemas as the real server. +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + + cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1" + appsv1alpha1 "github.com/cozystack/cozystack/pkg/apis/apps/v1alpha1" + "github.com/cozystack/cozystack/pkg/apiserver" + cozyserver "github.com/cozystack/cozystack/pkg/cmd/server" + "github.com/cozystack/cozystack/pkg/config" + sampleopenapi "github.com/cozystack/cozystack/pkg/generated/openapi" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + metainternalversion "k8s.io/apimachinery/pkg/apis/meta/internalversion" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/apiserver/pkg/endpoints/openapi" + "k8s.io/apiserver/pkg/registry/rest" + genericapiserver "k8s.io/apiserver/pkg/server" + utilfeature "k8s.io/apiserver/pkg/util/feature" + restclient "k8s.io/client-go/rest" + basecompatibility "k8s.io/component-base/compatibility" + baseversion "k8s.io/component-base/version" + "sigs.k8s.io/yaml" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +} + +func run() error { + // Find all ApplicationDefinition YAML files + pattern := "packages/system/*-rd/cozyrds/*.yaml" + matches, err := filepath.Glob(pattern) + if err != nil { + return fmt.Errorf("glob %q: %w", pattern, err) + } + if len(matches) == 0 { + return fmt.Errorf("no files matched %q — run from repo root", pattern) + } + + // Parse ApplicationDefinitions and build ResourceConfig + var resources []config.Resource + for _, path := range matches { + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read %s: %w", path, err) + } + var appDef cozyv1alpha1.ApplicationDefinition + if err := yaml.Unmarshal(data, &appDef); err != nil { + return fmt.Errorf("parse %s: %w", path, err) + } + if appDef.Spec.Application.Kind == "" { + continue + } + resources = append(resources, config.Resource{ + Application: config.ApplicationConfig{ + Kind: appDef.Spec.Application.Kind, + Singular: appDef.Spec.Application.Singular, + Plural: appDef.Spec.Application.Plural, + OpenAPISchema: appDef.Spec.Application.OpenAPISchema, + }, + }) + } + + if len(resources) == 0 { + return fmt.Errorf("no ApplicationDefinitions found") + } + + resourceConfig := &config.ResourceConfig{Resources: resources} + + // Register dynamic types in the apiserver scheme (same as the real server) + if err := appsv1alpha1.RegisterDynamicTypes(apiserver.Scheme, resourceConfig); err != nil { + return fmt.Errorf("register dynamic types: %w", err) + } + + version := os.Getenv("VERSION") + if version == "" { + version = "dev" + } + + // Create a minimal GenericAPIServer config + serverConfig := genericapiserver.NewConfig(apiserver.Codecs) + serverConfig.ExternalAddress = "localhost:443" + serverConfig.LoopbackClientConfig = &restclient.Config{} + serverConfig.FeatureGate = utilfeature.DefaultMutableFeatureGate + if baseversion.DefaultKubeBinaryVersion != "" { + serverConfig.EffectiveVersion = basecompatibility.NewEffectiveVersionFromString(baseversion.DefaultKubeBinaryVersion, "", "") + } + + // OpenAPI v3 only — the v2 config is required by the framework but we only extract v3. + kindSchemas := cozyserver.KindSchemasFromConfig(resourceConfig) + serverConfig.OpenAPIConfig = genericapiserver.DefaultOpenAPIConfig( + sampleopenapi.GetOpenAPIDefinitions, openapi.NewDefinitionNamer(apiserver.Scheme), + ) + serverConfig.OpenAPIV3Config = genericapiserver.DefaultOpenAPIV3Config( + sampleopenapi.GetOpenAPIDefinitions, openapi.NewDefinitionNamer(apiserver.Scheme), + ) + serverConfig.OpenAPIV3Config.Info.Title = "Cozystack apps.cozystack.io API" + serverConfig.OpenAPIV3Config.Info.Version = version + serverConfig.OpenAPIV3Config.PostProcessSpec = cozyserver.BuildPostProcessV3(kindSchemas) + + // Create the server + completed := serverConfig.Complete(nil) + server, err := completed.New("openapi-gen", genericapiserver.NewEmptyDelegate()) + if err != nil { + return fmt.Errorf("create server: %w", err) + } + + // Install apps API group with stub REST storage + appsStorage := map[string]rest.Storage{} + for _, res := range resourceConfig.Resources { + appsStorage[res.Application.Plural] = &stubREST{ + gvk: schema.GroupVersion{ + Group: "apps.cozystack.io", + Version: "v1alpha1", + }.WithKind(res.Application.Kind), + singularName: res.Application.Singular, + } + } + if err := apiserver.InstallAppsAPIGroup(server, appsStorage); err != nil { + return fmt.Errorf("install apps API group: %w", err) + } + + // PrepareRun triggers OpenAPI spec generation + server.PrepareRun() + + // Extract the v3 spec by hitting the server's HTTP handler directly + v3Path := "/openapi/v3/apis/apps.cozystack.io/v1alpha1" + req, err := http.NewRequest("GET", v3Path, nil) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + req.Header.Set("Accept", "application/json") + rec := httptest.NewRecorder() + server.Handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + return fmt.Errorf("GET %s returned %d: %s", v3Path, rec.Code, rec.Body.String()) + } + + // Pretty-print the JSON + var raw json.RawMessage + if err := json.Unmarshal(rec.Body.Bytes(), &raw); err != nil { + return fmt.Errorf("parse v3 response: %w", err) + } + + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(raw) +} + +// stubREST implements the same REST interfaces as the real application.REST +// but never handles actual requests. It exists solely so the K8s framework +// generates the correct OpenAPI paths and schemas. +type stubREST struct { + gvk schema.GroupVersionKind + singularName string +} + +// Compile-time interface checks — same set as the real application.REST. +var ( + _ rest.Getter = &stubREST{} + _ rest.Lister = &stubREST{} + _ rest.Creater = &stubREST{} + _ rest.Updater = &stubREST{} + _ rest.Patcher = &stubREST{} + _ rest.GracefulDeleter = &stubREST{} + _ rest.Watcher = &stubREST{} +) + +func (s *stubREST) New() runtime.Object { + obj := &appsv1alpha1.Application{} + obj.TypeMeta = metav1.TypeMeta{ + APIVersion: s.gvk.GroupVersion().String(), + Kind: s.gvk.Kind, + } + return obj +} + +func (s *stubREST) NewList() runtime.Object { + obj := &appsv1alpha1.ApplicationList{} + obj.TypeMeta = metav1.TypeMeta{ + APIVersion: s.gvk.GroupVersion().String(), + Kind: s.gvk.Kind + "List", + } + return obj +} + +func (s *stubREST) Destroy() {} +func (s *stubREST) NamespaceScoped() bool { return true } +func (s *stubREST) GetSingularName() string { return s.singularName } +func (s *stubREST) GroupVersionKind(schema.GroupVersion) schema.GroupVersionKind { return s.gvk } + +func (s *stubREST) Get(_ context.Context, _ string, _ *metav1.GetOptions) (runtime.Object, error) { + return nil, fmt.Errorf("stub: not implemented") +} + +func (s *stubREST) List(_ context.Context, _ *metainternalversion.ListOptions) (runtime.Object, error) { + return nil, fmt.Errorf("stub: not implemented") +} + +func (s *stubREST) Create(_ context.Context, _ runtime.Object, _ rest.ValidateObjectFunc, _ *metav1.CreateOptions) (runtime.Object, error) { + return nil, fmt.Errorf("stub: not implemented") +} + +func (s *stubREST) Update(_ context.Context, _ string, _ rest.UpdatedObjectInfo, _ rest.ValidateObjectFunc, _ rest.ValidateObjectUpdateFunc, _ bool, _ *metav1.UpdateOptions) (runtime.Object, bool, error) { + return nil, false, fmt.Errorf("stub: not implemented") +} + +func (s *stubREST) Delete(_ context.Context, _ string, _ rest.ValidateObjectFunc, _ *metav1.DeleteOptions) (runtime.Object, bool, error) { + return nil, false, fmt.Errorf("stub: not implemented") +} + +func (s *stubREST) Watch(_ context.Context, _ *metainternalversion.ListOptions) (watch.Interface, error) { + return nil, fmt.Errorf("stub: not implemented") +} + +func (s *stubREST) ConvertToTable(_ context.Context, _ runtime.Object, _ runtime.Object) (*metav1.Table, error) { + return nil, fmt.Errorf("stub: not implemented") +}