Schemas: Generate CRDs for core kinds (#62641)
Co-authored-by: sam boyer <sdboyer@grafana.com>
This commit is contained in:
co-authored by
sam boyer
parent
b95eda045a
commit
e70d623f90
@@ -0,0 +1,214 @@
|
||||
package codegen
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"cuelang.org/go/cue"
|
||||
"cuelang.org/go/cue/ast"
|
||||
"cuelang.org/go/encoding/openapi"
|
||||
cueyaml "cuelang.org/go/pkg/encoding/yaml"
|
||||
"github.com/grafana/codejen"
|
||||
"github.com/grafana/grafana/pkg/kindsys"
|
||||
"github.com/grafana/grafana/pkg/kindsys/k8ssys"
|
||||
"github.com/grafana/thema"
|
||||
goyaml "gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// TODO this jenny is quite sloppy, having been quickly adapted from app-sdk. It needs love
|
||||
|
||||
// YamlCRDJenny generates a representation of a core structured kind in YAML CRD form.
|
||||
func YamlCRDJenny(path string) OneToOne {
|
||||
return yamlCRDJenny{
|
||||
parentpath: path,
|
||||
}
|
||||
}
|
||||
|
||||
type yamlCRDJenny struct {
|
||||
parentpath string
|
||||
}
|
||||
|
||||
func (yamlCRDJenny) JennyName() string {
|
||||
return "YamlCRDJenny"
|
||||
}
|
||||
|
||||
func (j yamlCRDJenny) Generate(k kindsys.Kind) (*codejen.File, error) {
|
||||
kind, is := k.(kindsys.Core)
|
||||
if !is {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
props := kind.Def().Properties
|
||||
lin := kind.Lineage()
|
||||
|
||||
// We need to go through every schema, as they all have to be defined in the CRD
|
||||
sch, err := lin.Schema(thema.SV(0, 0))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resource := customResourceDefinition{
|
||||
APIVersion: "apiextensions.k8s.io/v1",
|
||||
Kind: "CustomResourceDefinition",
|
||||
Metadata: customResourceDefinitionMetadata{
|
||||
Name: fmt.Sprintf("%s.%s", props.PluralMachineName, props.CRD.Group),
|
||||
},
|
||||
Spec: k8ssys.CustomResourceDefinitionSpec{
|
||||
Group: props.CRD.Group,
|
||||
Scope: props.CRD.Scope,
|
||||
Names: k8ssys.CustomResourceDefinitionSpecNames{
|
||||
Kind: props.Name,
|
||||
Plural: props.PluralMachineName,
|
||||
},
|
||||
Versions: make([]k8ssys.CustomResourceDefinitionSpecVersion, 0),
|
||||
},
|
||||
}
|
||||
latest := lin.Latest().Version()
|
||||
|
||||
for sch != nil {
|
||||
oapi, err := generateOpenAPI(sch, props)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
vstr := versionString(sch.Version())
|
||||
if props.Maturity.Less(kindsys.MaturityStable) {
|
||||
vstr = "v0-0alpha1"
|
||||
}
|
||||
|
||||
ver, err := valueToCRDSpecVersion(oapi, vstr, sch.Version() == latest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if props.CRD.DummySchema {
|
||||
ver.Schema = map[string]any{
|
||||
"openAPIV3Schema": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"spec": map[string]any{
|
||||
"type": "object",
|
||||
"x-kubernetes-preserve-unknown-fields": true,
|
||||
},
|
||||
},
|
||||
"required": []any{
|
||||
"spec",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
resource.Spec.Versions = append(resource.Spec.Versions, ver)
|
||||
sch = sch.Successor()
|
||||
}
|
||||
contents, err := goyaml.Marshal(resource)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if props.CRD.DummySchema {
|
||||
// Add a comment header for those with dummy schema
|
||||
b := new(bytes.Buffer)
|
||||
fmt.Fprintf(b, "# This CRD is generated with an empty schema body because Grafana's\n# code generators currently produce OpenAPI that Kubernetes will not\n# accept, despite being valid.\n\n%s", string(contents))
|
||||
contents = b.Bytes()
|
||||
}
|
||||
|
||||
return codejen.NewFile(filepath.Join(j.parentpath, props.MachineName, "crd", props.MachineName+".crd.yml"), contents, j), nil
|
||||
}
|
||||
|
||||
// customResourceDefinition differs from k8ssys.CustomResourceDefinition in that it doesn't use the metav1
|
||||
// TypeMeta and ObjectMeta, as those do not contain YAML tags and get improperly serialized to YAML.
|
||||
// Since we don't need to use it with the kubernetes go-client, we don't need the extra functionality attached.
|
||||
//
|
||||
//nolint:lll
|
||||
type customResourceDefinition struct {
|
||||
Kind string `json:"kind,omitempty" yaml:"kind,omitempty" protobuf:"bytes,1,opt,name=kind"`
|
||||
APIVersion string `json:"apiVersion,omitempty" yaml:"apiVersion,omitempty" protobuf:"bytes,2,opt,name=apiVersion"`
|
||||
Metadata customResourceDefinitionMetadata `json:"metadata,omitempty" yaml:"metadata,omitempty"`
|
||||
Spec k8ssys.CustomResourceDefinitionSpec `json:"spec"`
|
||||
}
|
||||
|
||||
type customResourceDefinitionMetadata struct {
|
||||
Name string `json:"name,omitempty" yaml:"name" protobuf:"bytes,1,opt,name=name"`
|
||||
// TODO: other fields as necessary for codegen
|
||||
}
|
||||
|
||||
type cueOpenAPIEncoded struct {
|
||||
Components cueOpenAPIEncodedComponents `json:"components"`
|
||||
}
|
||||
|
||||
type cueOpenAPIEncodedComponents struct {
|
||||
Schemas map[string]any `json:"schemas"`
|
||||
}
|
||||
|
||||
func valueToCRDSpecVersion(str string, name string, stored bool) (k8ssys.CustomResourceDefinitionSpecVersion, error) {
|
||||
// Decode the bytes back into an object where we can trim the openAPI clutter out
|
||||
// and grab just the schema as a map[string]any (which is what k8s wants)
|
||||
back := cueOpenAPIEncoded{}
|
||||
err := goyaml.Unmarshal([]byte(str), &back)
|
||||
if err != nil {
|
||||
return k8ssys.CustomResourceDefinitionSpecVersion{}, err
|
||||
}
|
||||
if len(back.Components.Schemas) != 1 {
|
||||
// There should only be one schema here...
|
||||
// TODO: this may change with subresources--but subresources should have defined names
|
||||
return k8ssys.CustomResourceDefinitionSpecVersion{}, fmt.Errorf("version %s has multiple schemas", name)
|
||||
}
|
||||
var def map[string]any
|
||||
for _, v := range back.Components.Schemas {
|
||||
ok := false
|
||||
def, ok = v.(map[string]any)
|
||||
if !ok {
|
||||
return k8ssys.CustomResourceDefinitionSpecVersion{},
|
||||
fmt.Errorf("error generating openapi schema - generated schema has invalid type")
|
||||
}
|
||||
}
|
||||
|
||||
return k8ssys.CustomResourceDefinitionSpecVersion{
|
||||
Name: name,
|
||||
Served: true,
|
||||
Storage: stored,
|
||||
Schema: map[string]any{
|
||||
"openAPIV3Schema": map[string]any{
|
||||
"properties": map[string]any{
|
||||
"spec": def,
|
||||
},
|
||||
"required": []any{
|
||||
"spec",
|
||||
},
|
||||
"type": "object",
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func versionString(version thema.SyntacticVersion) string {
|
||||
return fmt.Sprintf("v%d-%d", version[0], version[1])
|
||||
}
|
||||
|
||||
// Hoisting this out of thema until we resolve the proper approach there
|
||||
func generateOpenAPI(sch thema.Schema, props kindsys.CoreProperties) (string, error) {
|
||||
ctx := sch.Underlying().Context()
|
||||
v := ctx.CompileString(fmt.Sprintf("#%s: _", props.Name))
|
||||
defpath := cue.MakePath(cue.Def(props.Name))
|
||||
defsch := v.FillPath(defpath, sch.Underlying())
|
||||
|
||||
cfg := &openapi.Config{
|
||||
NameFunc: func(v cue.Value, path cue.Path) string {
|
||||
if path.String() == defpath.String() {
|
||||
return props.Name
|
||||
}
|
||||
return ""
|
||||
},
|
||||
Info: ast.NewStruct( // doesn't matter, we're throwing it away
|
||||
"title", ast.NewString(props.Name),
|
||||
"version", ast.NewString("0.0"),
|
||||
),
|
||||
}
|
||||
|
||||
f, err := openapi.Generate(defsch, cfg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return cueyaml.Marshal(sch.Lineage().Runtime().Context().BuildFile(f))
|
||||
}
|
||||
Reference in New Issue
Block a user