[docs] Added openapi generation tool

Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
This commit is contained in:
Myasnikov Daniil 2026-03-16 14:12:43 +05:00
parent e090e2f317
commit 9ef0e2ceeb
No known key found for this signature in database
GPG key ID: FA953A439C637F04
6 changed files with 302 additions and 29 deletions

View file

@ -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

View file

@ -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

View file

@ -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 {

View file

@ -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]

View file

@ -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

247
tools/openapi-gen/main.go Normal file
View file

@ -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")
}