Files
kubevela/pkg/definition/gen_sdk/gen_sdk.go
Tianxin Dong 4f8bf44684 Refactor: use cuex engine (#6575)
* refactor: use cuex engine

Signed-off-by: FogDong <fog@bentoml.com>

* fix: fix lint

Signed-off-by: FogDong <fog@bentoml.com>

* fix: fix unit test

Signed-off-by: FogDong <fog@bentoml.com>

* fix: fix static check and sdk tests

Signed-off-by: FogDong <fog@bentoml.com>

* fix: fix testdata

Signed-off-by: FogDong <fog@bentoml.com>

* fix: fix velaql unit test

Signed-off-by: FogDong <fog@bentoml.com>

* fix: fix docgen parser

Signed-off-by: FogDong <fog@bentoml.com>

* fix: fix cuegen

Signed-off-by: FogDong <fog@bentoml.com>

* fix: fix velaql

Signed-off-by: FogDong <fog@bentoml.com>

* fix: delete useless print

Signed-off-by: FogDong <fog@bentoml.com>

* fix: set client for ql

Signed-off-by: FogDong <fog@bentoml.com>

* fix: fix mt tests

Signed-off-by: FogDong <fog@bentoml.com>

* fix: set kubeclient in generator

Signed-off-by: FogDong <fog@bentoml.com>

* fix: use pass kube client

Signed-off-by: FogDong <fog@bentoml.com>

* fix: simplify ql

Signed-off-by: FogDong <fog@bentoml.com>

* fix: fix lint

Signed-off-by: FogDong <fog@bentoml.com>

* fix: add wf debug back

Signed-off-by: FogDong <fog@bentoml.com>

* fix: add loader

Signed-off-by: FogDong <fog@bentoml.com>

---------

Signed-off-by: FogDong <fog@bentoml.com>
2024-07-27 17:44:20 +08:00

725 lines
20 KiB
Go

/*
Copyright 2021 The KubeVela 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.
*/
package gen_sdk
import (
"bytes"
"context"
"fmt"
"io"
"io/fs"
"os"
"os/exec"
"path"
"path/filepath"
"reflect"
"runtime"
"runtime/debug"
"strings"
"cuelang.org/go/cue"
"cuelang.org/go/encoding/openapi"
"github.com/getkin/kin-openapi/openapi3"
"github.com/kubevela/pkg/cue/cuex"
"github.com/kubevela/pkg/util/slices"
"github.com/pkg/errors"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/client-go/rest"
"k8s.io/klog/v2"
velacue "github.com/oam-dev/kubevela/pkg/cue"
"github.com/oam-dev/kubevela/pkg/definition"
"github.com/oam-dev/kubevela/pkg/utils/common"
"github.com/oam-dev/kubevela/pkg/utils/system"
"github.com/oam-dev/kubevela/pkg/workflow/providers"
)
type byteHandler func([]byte) []byte
var (
defaultAPIDir = map[string]string{
"go": "pkg/apis",
}
// LangArgsRegistry is used to store the argument info
LangArgsRegistry = map[string]map[langArgKey]LangArg{}
)
// GenMeta stores the metadata for generator.
type GenMeta struct {
config *rest.Config
name string
kind string
Output string
APIDirectory string
IsSubModule bool
Lang string
Package string
Template string
File []string
InitSDK bool
Verbose bool
LangArgs LanguageArgs
cuePaths []string
templatePath string
packageFunc byteHandler
}
// Generator is used to generate SDK code from CUE template for one language.
type Generator struct {
meta *GenMeta
def definition.Definition
openapiSchema []byte
// defModifiers are the modifiers for each definition.
defModifiers []Modifier
// moduleModifiers are the modifiers for the whole module. It will be executed after generating all definitions.
moduleModifiers []Modifier
}
// LanguageArgs is used to store the arguments for the language.
type LanguageArgs interface {
Get(key langArgKey) string
Set(key langArgKey, value string)
}
// langArgKey is language argument key.
type langArgKey string
// LangArg is language-specific argument.
type LangArg struct {
Name langArgKey
Desc string
Default string
}
// registerLangArg should be called in init() function of each language.
func registerLangArg(lang string, arg ...LangArg) {
if _, ok := LangArgsRegistry[lang]; !ok {
LangArgsRegistry[lang] = map[langArgKey]LangArg{}
}
for _, a := range arg {
LangArgsRegistry[lang][a.Name] = a
}
}
// NewLanguageArgs parses the language arguments and returns a LanguageArgs.
func NewLanguageArgs(lang string, langArgs []string) (LanguageArgs, error) {
availableArgs := LangArgsRegistry[lang]
res := languageArgs{}
for _, arg := range langArgs {
parts := strings.Split(arg, "=")
if len(parts) != 2 {
return nil, errors.Errorf("argument %s is not in the format of key=value", arg)
}
if _, ok := availableArgs[langArgKey(parts[0])]; !ok {
return nil, errors.Errorf("argument %s is not supported for language %s", parts[0], lang)
}
res.Set(langArgKey(parts[0]), parts[1])
}
for k, v := range availableArgs {
if res.Get(k) == "" {
res.Set(k, v.Default)
}
}
return res, nil
}
type languageArgs map[string]string
func (l languageArgs) Get(key langArgKey) string {
return l[string(key)]
}
func (l languageArgs) Set(key langArgKey, value string) {
l[string(key)] = value
}
// Modifier is used to modify the generated code.
type Modifier interface {
Modify() error
Name() string
}
// Init initializes the generator.
// It will validate the param, analyze the CUE files, read them to memory, mkdir for output.
func (meta *GenMeta) Init(c common.Args, langArgs []string) (err error) {
meta.config, err = c.GetConfig()
if err != nil {
klog.Info("No kubeconfig found, skipping")
}
if _, ok := SupportedLangs[meta.Lang]; !ok {
return fmt.Errorf("language %s is not supported", meta.Lang)
}
// Init arguments
if meta.APIDirectory == "" {
meta.APIDirectory = defaultAPIDir[meta.Lang]
}
meta.LangArgs, err = NewLanguageArgs(meta.Lang, langArgs)
if err != nil {
return err
}
packageFuncs := map[string]byteHandler{
"go": func(b []byte) []byte {
return bytes.ReplaceAll(b, []byte(PackagePlaceHolder), []byte(meta.Package))
},
}
meta.packageFunc = packageFuncs[meta.Lang]
// Analyze the all cue files from meta.File. It can be file or directory. If directory is given, it will recursively
// analyze all cue files in the directory.
for _, f := range meta.File {
info, err := os.Stat(f)
if err != nil {
return err
}
if info.IsDir() {
err = filepath.Walk(f, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() && strings.HasSuffix(path, ".cue") {
meta.cuePaths = append(meta.cuePaths, path)
}
return nil
})
if err != nil {
return err
}
} else if strings.HasSuffix(f, ".cue") {
meta.cuePaths = append(meta.cuePaths, f)
}
}
return os.MkdirAll(meta.Output, 0750)
}
// CreateScaffold will create a scaffold for the given language.
// It will copy all files from embedded scaffold/{meta.Lang} to meta.Output.
func (meta *GenMeta) CreateScaffold() error {
if !meta.InitSDK {
return nil
}
klog.Info("Flag --init is set, creating scaffold...")
langDirPrefix := fmt.Sprintf("%s/%s", ScaffoldDir, meta.Lang)
err := fs.WalkDir(Scaffold, ScaffoldDir, func(_path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
if !strings.HasPrefix(_path, langDirPrefix) && _path != ScaffoldDir {
return fs.SkipDir
}
return nil
}
fileContent, err := Scaffold.ReadFile(_path)
if err != nil {
return err
}
fileContent = meta.packageFunc(fileContent)
fileName := path.Join(meta.Output, strings.TrimPrefix(_path, langDirPrefix))
// go.mod_ is a special file name, it will be renamed to go.mod. Go will ignore directory containing go.mod during the build process.
fileName = strings.ReplaceAll(fileName, "go.mod_", "go.mod")
fileDir := path.Dir(fileName)
if err = os.MkdirAll(fileDir, 0750); err != nil {
return err
}
return os.WriteFile(fileName, fileContent, 0600)
})
return err
}
// PrepareGeneratorAndTemplate will make a copy of the embedded openapi-generator-cli and templates/{meta.Lang} to local
func (meta *GenMeta) PrepareGeneratorAndTemplate() error {
var err error
ogImageName := "openapitools/openapi-generator-cli"
ogImageTag := "v6.3.0"
ogImage := fmt.Sprintf("%s:%s", ogImageName, ogImageTag)
homeDir, err := system.GetVelaHomeDir()
if err != nil {
return err
}
sdkDir := path.Join(homeDir, "sdk")
if err = os.MkdirAll(sdkDir, 0750); err != nil {
return err
}
// nolint:gosec
output, err := exec.Command("docker", "image", "ls", ogImage).CombinedOutput()
if err != nil {
return errors.Wrapf(err, "failed to check image %s: %s", ogImage, output)
}
if !strings.Contains(string(output), ogImageName) {
// nolint:gosec
output, err = exec.Command("docker", "pull", ogImage).CombinedOutput()
if err != nil {
return errors.Wrapf(err, "failed to pull %s: %s", ogImage, output)
}
}
// copy embedded templates/{meta.Lang} to sdkDir
if meta.Template == "" {
langDir := path.Join(sdkDir, "templates", meta.Lang)
if err = os.MkdirAll(langDir, 0750); err != nil {
return err
}
langTemplateDir := path.Join("openapi-generator", "templates", meta.Lang)
langTemplateFiles, err := Templates.ReadDir(langTemplateDir)
if err != nil {
return err
}
for _, langTemplateFile := range langTemplateFiles {
src, err := Templates.Open(path.Join(langTemplateDir, langTemplateFile.Name()))
if err != nil {
return err
}
// nolint:gosec
dst, err := os.Create(path.Join(langDir, langTemplateFile.Name()))
if err != nil {
return err
}
_, err = io.Copy(dst, src)
_ = dst.Close()
_ = src.Close()
if err != nil {
return err
}
}
meta.templatePath = langDir
} else {
meta.templatePath, err = filepath.Abs(meta.Template)
if err != nil {
return errors.Wrap(err, "failed to get absolute path of template")
}
}
return nil
}
// Run will generally do two thing:
// 1. Generate OpenAPI schema from cue files
// 2. Generate code from OpenAPI schema
func (meta *GenMeta) Run(ctx context.Context) error {
g := NewModifiableGenerator(meta)
if len(meta.cuePaths) == 0 {
return nil
}
APIGenerated := false
for _, cuePath := range meta.cuePaths {
klog.Infof("Generating API for %s", cuePath)
// nolint:gosec
cueBytes, err := os.ReadFile(cuePath)
if err != nil {
return errors.Wrapf(err, "failed to read %s", cuePath)
}
template, defName, defKind, err := g.GetDefinitionValue(ctx, cueBytes)
if err != nil {
return err
}
g.meta.SetDefinition(defName, defKind)
err = g.GenOpenAPISchema(template)
if err != nil {
if strings.Contains(err.Error(), "unsupported node string (*ast.Ident)") {
// https://github.com/cue-lang/cue/issues/2259
klog.Warningf("Skip generating OpenAPI schema for %s, known issue: %s", cuePath, err.Error())
continue
}
return errors.Wrapf(err, "generate OpenAPI schema")
}
err = g.GenerateCode()
if err != nil {
return err
}
APIGenerated = true
}
if !APIGenerated {
return nil
}
for _, m := range g.moduleModifiers {
err := m.Modify()
if err != nil {
return err
}
}
return nil
}
// SetDefinition sets definition name and kind
func (meta *GenMeta) SetDefinition(defName, defKind string) {
meta.name = defName
meta.kind = defKind
}
// GetDefinitionValue returns a value.Value definition name, definition kind from cue bytes
func (g *Generator) GetDefinitionValue(ctx context.Context, cueBytes []byte) (cue.Value, string, string, error) {
g.def = definition.Definition{Unstructured: unstructured.Unstructured{}}
if err := g.def.FromCUEString(string(cueBytes), g.meta.config); err != nil {
return cue.Value{}, "", "", errors.Wrapf(err, "failed to parse CUE")
}
templateString, _, err := unstructured.NestedString(g.def.Object, definition.DefinitionTemplateKeys...)
if err != nil {
return cue.Value{}, "", "", err
}
if templateString == "" {
return cue.Value{}, "", "", errors.New("definition doesn't include cue schematic")
}
template, err := providers.Compiler.Get().CompileStringWithOptions(ctx, templateString+velacue.BaseTemplate, cuex.DisableResolveProviderFunctions{})
if err != nil {
return cue.Value{}, "", "", err
}
return template, g.def.GetName(), g.def.GetKind(), nil
}
// GenOpenAPISchema generates OpenAPI json schema from cue.Instance
func (g *Generator) GenOpenAPISchema(val cue.Value) error {
var err error
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("invalid cue definition to generate open api: %v", r)
debug.PrintStack()
return
}
}()
if val.Err() != nil {
return val.Err()
}
paramOnlyVal, err := common.RefineParameterValue(val)
if err != nil {
return err
}
defaultConfig := &openapi.Config{ExpandReferences: false, NameFunc: func(val cue.Value, path cue.Path) string {
sels := path.Selectors()
lastLabel := sels[len(sels)-1].String()
return strings.TrimPrefix(lastLabel, "#")
}, DescriptionFunc: func(v cue.Value) string {
for _, d := range v.Doc() {
if strings.HasPrefix(d.Text(), "+usage=") {
return strings.TrimPrefix(d.Text(), "+usage=")
}
}
return ""
}}
b, err := openapi.Gen(paramOnlyVal, defaultConfig)
if err != nil {
return err
}
doc, err := openapi3.NewLoader().LoadFromData(b)
if err != nil {
return err
}
g.completeOpenAPISchema(doc)
openapiSchema, err := doc.MarshalJSON()
g.openapiSchema = openapiSchema
if g.meta.Verbose {
klog.Info("OpenAPI schema:")
klog.Info(string(g.openapiSchema))
}
return err
}
func (g *Generator) completeOpenAPISchema(doc *openapi3.T) {
for key, schema := range doc.Components.Schemas {
switch key {
case "parameter":
spec := g.meta.name + "-spec"
schema.Value.Title = spec
completeFreeFormSchema(schema)
completeSchema(key, schema)
doc.Components.Schemas[spec] = schema
delete(doc.Components.Schemas, key)
case g.meta.name + "-spec":
continue
default:
completeSchema(key, schema)
}
}
}
// GenerateCode will call openapi-generator to generate code and modify it
func (g *Generator) GenerateCode() (err error) {
tmpFile, err := os.CreateTemp("", g.meta.name+"-*.json")
if err != nil {
return err
}
_, err = tmpFile.Write(g.openapiSchema)
if err != nil {
return errors.Wrap(err, "write openapi schema to temporary file")
}
defer func() {
_ = tmpFile.Close()
if err == nil {
_ = os.Remove(tmpFile.Name())
}
}()
apiDir, err := filepath.Abs(path.Join(g.meta.Output, g.meta.APIDirectory))
if err != nil {
return errors.Wrapf(err, "get absolute path of %s", apiDir)
}
err = os.MkdirAll(path.Join(apiDir, definition.DefinitionKindToType[g.meta.kind]), 0750)
if err != nil {
return errors.Wrapf(err, "create directory %s", apiDir)
}
// nolint:gosec
cmd := exec.Command("docker", "run",
"-v", fmt.Sprintf("%s:/local/output", apiDir),
"-v", fmt.Sprintf("%s:/local/input", filepath.Dir(tmpFile.Name())),
"-v", fmt.Sprintf("%s:/local/template", g.meta.templatePath),
"-u", fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid()),
"--rm",
"openapitools/openapi-generator-cli:v6.3.0",
"generate",
"-i", "/local/input/"+filepath.Base(tmpFile.Name()),
"-g", g.meta.Lang,
"-o", fmt.Sprintf("/local/output/%s/%s", definition.DefinitionKindToType[g.meta.kind], g.meta.name),
"-t", "/local/template",
"--skip-validate-spec",
"--enable-post-process-file",
"--generate-alias-as-model",
"--inline-schema-name-defaults", "arrayItemSuffix=,mapItemSuffix=",
"--additional-properties", fmt.Sprintf("packageName=%s", strings.ReplaceAll(g.meta.name, "-", "_")),
"--global-property", "modelDocs=false,models,supportingFiles=utils.go",
)
if g.meta.Verbose {
klog.Info(cmd.String())
}
output, err := cmd.CombinedOutput()
if err != nil {
return errors.Wrap(err, string(output))
}
if g.meta.Verbose {
klog.Info(string(output))
}
// Adjust the generated files and code
for _, m := range g.defModifiers {
err := m.Modify()
if err != nil {
return errors.Wrapf(err, "modify fail by %s", m.Name())
}
}
return nil
}
// completeFreeFormSchema can complete the schema of free form parameter, such as `parameter: {...}`
// This is a workaround for openapi-generator, which won't generate the correct code for free form parameter.
func completeFreeFormSchema(schema *openapi3.SchemaRef) {
v := schema.Value
if v.OneOf == nil && v.AnyOf == nil && v.AllOf == nil && v.Properties == nil {
if v.Type == openapi3.TypeObject {
schema.Value.AdditionalProperties = openapi3.AdditionalProperties{Schema: &openapi3.SchemaRef{
Value: &openapi3.Schema{
Type: openapi3.TypeObject,
Nullable: true,
},
}}
} else if v.Type == "string" {
schema.Value.AdditionalProperties = openapi3.AdditionalProperties{Schema: &openapi3.SchemaRef{
Value: &openapi3.Schema{
Type: "string",
},
}}
}
}
}
// fixSchemaWithOneOf do some fix for schema with OneOf.
// 1. move properties in the root schema to sub schema in OneOf. See https://github.com/OpenAPITools/openapi-generator/issues/14250
// 2. move default value to sub schema in OneOf.
// 3. remove duplicated type in OneOf.
func fixSchemaWithOneOf(schema *openapi3.SchemaRef) {
var schemaNeedFix []*openapi3.Schema
oneOf := schema.Value.OneOf
typeSet := make(map[string]struct{})
duplicateIndex := make([]int, 0)
// If the schema have default value, it should be moved to sub-schema with right type.
defaultValue := schema.Value.Default
schema.Value.Default = nil
for _, s := range oneOf {
completeSchemas(s.Value.Properties)
if defaultValueMatchOneOfItem(s.Value, defaultValue) {
s.Value.Default = defaultValue
}
// If the schema is without type or ref. It may need to be fixed.
// Cases can be:
// 1. A non-ref sub-schema maybe have no properties and the needed properties is in the root schema.
// 2. A sub-schema maybe have no type and the needed type is in the root schema.
// In both cases, we need to complete the sub-schema with the properties or type in the root schema if any of them is missing.
if s.Value.Properties == nil || s.Value.Type == "" {
schemaNeedFix = append(schemaNeedFix, s.Value)
}
}
if schemaNeedFix == nil {
return // no non-ref schema found
}
for _, s := range schemaNeedFix {
if s.Properties == nil {
s.Properties = schema.Value.Properties
}
if s.Type == "" {
s.Type = schema.Value.Type
}
}
schema.Value.Properties = nil
// remove duplicated type
for i, s := range oneOf {
if s.Value.Type == "" {
continue
}
if _, ok := typeSet[s.Value.Type]; ok && s.Value.Type != openapi3.TypeObject {
duplicateIndex = append(duplicateIndex, i)
} else {
typeSet[s.Value.Type] = struct{}{}
}
}
if len(duplicateIndex) > 0 {
newRefs := make(openapi3.SchemaRefs, 0, len(oneOf)-len(duplicateIndex))
for i, s := range oneOf {
if !slices.Contains(duplicateIndex, i) {
newRefs = append(newRefs, s)
}
}
schema.Value.OneOf = newRefs
}
}
func completeSchema(key string, schema *openapi3.SchemaRef) {
schema.Value.Title = key
if schema.Value.OneOf != nil {
fixSchemaWithOneOf(schema)
return
}
// allow all the fields to be empty to avoid this case:
// A field is initialized with empty value and marshalled to JSON with empty value (e.g. empty string)
// However, the empty value is not allowed on the server side when it is conflict with the default value in CUE.
// schema.Value.Required = []string{}
switch schema.Value.Type {
case openapi3.TypeObject:
completeSchemas(schema.Value.Properties)
case openapi3.TypeArray:
completeSchema(key, schema.Value.Items)
}
}
func completeSchemas(schemas openapi3.Schemas) {
for k, schema := range schemas {
completeSchema(k, schema)
}
}
// NewModifiableGenerator returns a new Generator with modifiers
func NewModifiableGenerator(meta *GenMeta) *Generator {
g := &Generator{
meta: meta,
defModifiers: []Modifier{},
moduleModifiers: []Modifier{},
}
appendModifiersByLanguage(g, meta)
return g
}
func appendModifiersByLanguage(g *Generator, meta *GenMeta) {
switch meta.Lang {
case "go":
g.defModifiers = append(g.defModifiers, &GoDefModifier{GenMeta: meta})
g.moduleModifiers = append(g.moduleModifiers, &GoModuleModifier{GenMeta: meta})
default:
panic(fmt.Sprintf("unsupported language: %s", meta.Lang))
}
}
// getValueType returns the cue type of the value
func getValueType(i interface{}) CUEType {
if i == nil {
return ""
}
switch i.(type) {
case string:
return "string"
case int:
return "integer"
case float64, float32:
return "number"
case bool:
return "boolean"
case map[string]interface{}:
return "object"
case []interface{}:
return "array"
default:
return ""
}
}
// CUEType is the possible types in CUE
type CUEType string
func (t CUEType) fit(schema *openapi3.Schema) bool {
openapiType := schema.Type
switch t {
case "string":
return openapiType == "string"
case "integer":
return openapiType == "integer" || openapiType == "number"
case "number":
return openapiType == "number"
case "boolean":
return openapiType == "boolean"
case "array":
return openapiType == "array"
default:
return false
}
}
// defaultValueMatchOneOfItem checks if the default value matches one of the items in the oneOf schema.
func defaultValueMatchOneOfItem(item *openapi3.Schema, defaultValue interface{}) bool {
if item.Default != nil {
return false
}
defaultValueType := getValueType(defaultValue)
// let's skip the case that default value is object because it's hard to match now.
if defaultValueType == "" || defaultValueType == openapi3.TypeObject {
return false
}
if defaultValueType != "" && defaultValueType.fit(item) && item.Default == nil {
return true
}
return false
}
func fnName(fn interface{}) string {
return runtime.FuncForPC(reflect.ValueOf(fn).Pointer()).Name()
}