diff --git a/dev/tools/controllerbuilder/pkg/commands/generatetypes/generatetypescommand.go b/dev/tools/controllerbuilder/pkg/commands/generatetypes/generatetypescommand.go index b5a563e28b..a5139bddcb 100644 --- a/dev/tools/controllerbuilder/pkg/commands/generatetypes/generatetypescommand.go +++ b/dev/tools/controllerbuilder/pkg/commands/generatetypes/generatetypescommand.go @@ -22,6 +22,7 @@ import ( "github.com/GoogleCloudPlatform/k8s-config-connector/dev/tools/controllerbuilder/pkg/codegen" "github.com/GoogleCloudPlatform/k8s-config-connector/dev/tools/controllerbuilder/pkg/options" "github.com/GoogleCloudPlatform/k8s-config-connector/dev/tools/controllerbuilder/pkg/protoapi" + "github.com/GoogleCloudPlatform/k8s-config-connector/dev/tools/controllerbuilder/scaffold" "google.golang.org/protobuf/reflect/protoreflect" "k8s.io/apimachinery/pkg/runtime/schema" @@ -32,10 +33,13 @@ type GenerateCRDOptions struct { *options.GenerateOptions OutputAPIDirectory string + KindNames []string } func (o *GenerateCRDOptions) BindFlags(cmd *cobra.Command) { cmd.Flags().StringVar(&o.OutputAPIDirectory, "output-api", o.OutputAPIDirectory, "base directory for writing APIs") + // TODO: 1. only add API and mapper needed by this Kind 2. validate the kind should be in camel case + cmd.Flags().StringSliceVarP(&o.KindNames, "kinds", "k", nil, "the GCP resource names under the GCP service. This will be used as the ConfigConnecter resource Kind names and should be in camel case") } func BuildCommand(baseOptions *options.GenerateOptions) *cobra.Command { @@ -62,10 +66,22 @@ func BuildCommand(baseOptions *options.GenerateOptions) *cobra.Command { func RunGenerateCRD(ctx context.Context, o *GenerateCRDOptions) error { if o.ServiceName == "" { - return fmt.Errorf("ServiceName is required") + return fmt.Errorf("`service` is required") } if o.GenerateOptions.ProtoSourcePath == "" { - return fmt.Errorf("ProtoSourcePath is required") + return fmt.Errorf("`proto-source-path` is required") + } + + if o.KindNames != nil { + errMsg := []string{} + for _, k := range o.KindNames { + if strings.ToLower(k) == k { + errMsg = append(errMsg, "%q in `kinds` should be CamelCase.") + } + } + if len(errMsg) > 0 { + return fmt.Errorf(strings.Join(errMsg, "\n")) + } } gv, err := schema.ParseGroupVersion(o.APIVersion) @@ -78,6 +94,8 @@ func RunGenerateCRD(ctx context.Context, o *GenerateCRDOptions) error { return fmt.Errorf("loading proto: %w", err) } + goPackage := "" + protoPackagePath := "" pathForMessage := func(msg protoreflect.MessageDescriptor) (string, bool) { fullName := string(msg.FullName()) if strings.HasSuffix(fullName, "Request") { @@ -91,12 +109,13 @@ func RunGenerateCRD(ctx context.Context, o *GenerateCRDOptions) error { return "", false } - protoPackagePath := string(msg.ParentFile().Package()) + protoPackagePath = string(msg.ParentFile().Package()) protoPackagePath = strings.TrimPrefix(protoPackagePath, "google.") protoPackagePath = strings.TrimPrefix(protoPackagePath, "cloud.") protoPackagePath = strings.TrimSuffix(protoPackagePath, ".v1") protoPackagePath = strings.TrimSuffix(protoPackagePath, ".v1beta1") - goPackage := "apis/" + strings.Join(strings.Split(protoPackagePath, "."), "/") + "/" + gv.Version + protoPackagePath = strings.Join(strings.Split(protoPackagePath, "."), "/") + goPackage = "apis/" + protoPackagePath + "/" + gv.Version return goPackage, true } @@ -109,6 +128,33 @@ func RunGenerateCRD(ctx context.Context, o *GenerateCRDOptions) error { return err } + if o.KindNames != nil { + scaffolder := &scaffold.APIScaffolder{ + BaseDir: o.OutputAPIDirectory, + GoPackage: goPackage, + Service: protoPackagePath, + Version: gv.Version, + PackageProtoTag: o.ServiceName, + } + if scaffolder.DocFileNotExist() { + if err := scaffolder.AddDocFile(); err != nil { + return fmt.Errorf("add doc.go file: %w", err) + } + } + if scaffolder.GroupVersionFileNotExist() { + if err := scaffolder.AddGroupVersionFile(); err != nil { + return fmt.Errorf("add groupversion_info.go file: %w", err) + } + } + for _, kind := range o.KindNames { + if !scaffolder.TypeFileNotExist(kind) { + fmt.Printf("file %s already exist, skip", scaffolder.GetTypeFile(kind)) + continue + } + if err := scaffolder.AddTypeFile(kind); err != nil { + return fmt.Errorf("add type file %s: %w", scaffolder.GetTypeFile(kind), err) + } + } + } return nil - } diff --git a/dev/tools/controllerbuilder/scaffold/apis.go b/dev/tools/controllerbuilder/scaffold/apis.go new file mode 100644 index 0000000000..baa07fb64f --- /dev/null +++ b/dev/tools/controllerbuilder/scaffold/apis.go @@ -0,0 +1,162 @@ +// Copyright 2024 Google LLC +// +// 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 scaffold + +import ( + "bytes" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "text/template" + "unicode" + "unicode/utf8" + + "github.com/GoogleCloudPlatform/k8s-config-connector/dev/tools/controllerbuilder/template/apis" + "github.com/fatih/color" +) + +type APIScaffolder struct { + BaseDir string + GoPackage string + Service string + Version string + PackageProtoTag string +} + +func (a *APIScaffolder) TypeFileNotExist(kind string) bool { + typeFilePath := a.GetTypeFile(kind) + _, err := os.Stat(typeFilePath) + if err == nil { + return false + } + return errors.Is(err, os.ErrNotExist) +} + +func (a *APIScaffolder) GetTypeFile(kind string) string { + fileName := strings.ToLower(kind) + "_types.go" + return filepath.Join(a.BaseDir, a.GoPackage, fileName) +} + +func (a *APIScaffolder) AddTypeFile(kind string) error { + gcpResource := kind + if !strings.HasPrefix(strings.ToLower(kind), a.Service) { + s, size := utf8.DecodeRuneInString(a.Service) + uc := unicode.ToUpper(s) + kind = string(uc) + a.Service[size:] + kind + } + typeFilePath := a.GetTypeFile(kind) + cArgs := &apis.APIArgs{ + Service: a.Service, + Version: a.Version, + Kind: kind, + PackageProtoTag: a.PackageProtoTag, + KindProtoTag: a.PackageProtoTag + "." + gcpResource, + } + return scaffoldTypeFile(typeFilePath, cArgs) +} + +func scaffoldTypeFile(path string, cArgs *apis.APIArgs) error { + tmpl, err := template.New(cArgs.Kind).Parse(apis.TypesTemplate) + if err != nil { + return fmt.Errorf("parse %s_types.go template: %w", strings.ToLower(cArgs.Kind), err) + } + // Apply the APIArgs args to the template + out := &bytes.Buffer{} + if err := tmpl.Execute(out, cArgs); err != nil { + return err + } + // Write the generated _types.go + if err := WriteToFile(path, out.Bytes()); err != nil { + return err + } + // Format and adjust the go imports in the generated files. + if err := FormatImports(path, out.Bytes()); err != nil { + return err + } + color.HiGreen("New API file added %s\nPlease EDIT it!\n", path) + return nil +} + +func (a *APIScaffolder) GroupVersionFileNotExist() bool { + docFilePath := filepath.Join(a.BaseDir, a.GoPackage, "groupversion_info.go") + _, err := os.Stat(docFilePath) + if err == nil { + return false + } + return errors.Is(err, os.ErrNotExist) +} + +func (a *APIScaffolder) AddGroupVersionFile() error { + docFilePath := filepath.Join(a.BaseDir, a.GoPackage, "groupversion_info.go") + cArgs := &apis.APIArgs{ + Service: a.Service, + Version: a.Version, + PackageProtoTag: a.PackageProtoTag, + } + return scaffoldGropuVersionFile(docFilePath, cArgs) +} + +func (a *APIScaffolder) DocFileNotExist() bool { + docFilePath := filepath.Join(a.BaseDir, a.GoPackage, "doc.go") + _, err := os.Stat(docFilePath) + if err == nil { + return false + } + return errors.Is(err, os.ErrNotExist) +} + +func (a *APIScaffolder) AddDocFile() error { + docFilePath := filepath.Join(a.BaseDir, a.GoPackage, "doc.go") + cArgs := &apis.APIArgs{ + Service: a.Service, + Version: a.Version, + PackageProtoTag: a.PackageProtoTag, + } + return scaffoldDocFile(docFilePath, cArgs) +} + +func scaffoldDocFile(path string, cArgs *apis.APIArgs) error { + tmpl, err := template.New(cArgs.Service).Parse(apis.DocTemplate) + if err != nil { + return fmt.Errorf("parse doc.go template: %w", err) + } + out := &bytes.Buffer{} + if err := tmpl.Execute(out, cArgs); err != nil { + return err + } + if err := WriteToFile(path, out.Bytes()); err != nil { + return err + } + color.HiGreen("New file added %s!\n", path) + return nil +} + +func scaffoldGropuVersionFile(path string, cArgs *apis.APIArgs) error { + tmpl, err := template.New(cArgs.Service).Parse(apis.GroupVersionInfoTemplate) + if err != nil { + return fmt.Errorf("parse groupversion_info.go template: %w", err) + } + out := &bytes.Buffer{} + if err := tmpl.Execute(out, cArgs); err != nil { + return err + } + if err := WriteToFile(path, out.Bytes()); err != nil { + return err + } + color.HiGreen("New file added %s!\n", path) + return nil +} diff --git a/dev/tools/controllerbuilder/scaffold/controller.go b/dev/tools/controllerbuilder/scaffold/controller.go index 5ea3173a1c..d440adec9f 100644 --- a/dev/tools/controllerbuilder/scaffold/controller.go +++ b/dev/tools/controllerbuilder/scaffold/controller.go @@ -89,14 +89,17 @@ func FormatImports(path string, out []byte) error { } func WriteToFile(path string, out []byte) error { - f, err := os.Create(path) - if err != nil { - return fmt.Errorf("create controller file %s: %w", path, err) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("failed to create directory %q: %w", filepath.Dir(path), err) } - err = os.WriteFile(path, out, 0644) + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { - return fmt.Errorf("write controller file %s: %w", path, err) + return err } defer f.Close() + _, err = f.Write(out) + if err != nil { + return fmt.Errorf("write file %s: %w", path, err) + } return nil } diff --git a/dev/tools/controllerbuilder/template/apis/doc.go b/dev/tools/controllerbuilder/template/apis/doc.go new file mode 100644 index 0000000000..d80522dfad --- /dev/null +++ b/dev/tools/controllerbuilder/template/apis/doc.go @@ -0,0 +1,22 @@ +package apis + +const DocTemplate = ` +// Copyright 2024 Google LLC +// +// 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. + +{{- if .PackageProtoTag }} +// +kcc:proto={{ .PackageProtoTag }} +{{- end }} +package {{ .Version }} +` diff --git a/dev/tools/controllerbuilder/template/apis/groupversion_info.go b/dev/tools/controllerbuilder/template/apis/groupversion_info.go new file mode 100644 index 0000000000..7673078e88 --- /dev/null +++ b/dev/tools/controllerbuilder/template/apis/groupversion_info.go @@ -0,0 +1,37 @@ +package apis + +const GroupVersionInfoTemplate = ` +// Copyright 2024 Google LLC +// +// 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. + +// +kubebuilder:object:generate=true +// +groupName={{.Service}}.cnrm.cloud.google.com +package {{ .Version }} + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +var ( + // GroupVersion is group version used to register these objects + GroupVersion = schema.GroupVersion{Group: "{{.Service}}.cnrm.cloud.google.com", Version: "{{.Version}}"} + + // SchemeBuilder is used to add go types to the GroupVersionKind scheme + SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} + + // AddToScheme adds the types in this group-version to the given scheme. + AddToScheme = SchemeBuilder.AddToScheme +) +` diff --git a/dev/tools/controllerbuilder/template/apis/types.go b/dev/tools/controllerbuilder/template/apis/types.go new file mode 100644 index 0000000000..3efedc053c --- /dev/null +++ b/dev/tools/controllerbuilder/template/apis/types.go @@ -0,0 +1,110 @@ +package apis + +type APIArgs struct { + Service string + Version string + Kind string + GcpResource string + PackageProtoTag string + KindProtoTag string +} + +const TypesTemplate = ` +// Copyright 2024 Google LLC +// +// 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 {{ .Version }} + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var ( + {{ .Kind }}GVK = schema.GroupVersionKind{ + Group: SchemeGroupVersion.Group, + Version: SchemeGroupVersion.Version, + Kind: "{{ .Kind }}", + } +) + +// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! +// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. + +// {{ .Kind }}Spec defines the desired state of {{ .Kind }} +{{- if .KindProtoTag }} +// +kcc:proto={{ .KindProtoTag }} +{{- end }} +type {{ .Kind }}Spec struct { + // The {{ .Kind }} name. If not given, the metadata.name will be used. + // + optional + ResourceID *string ` + "`" + `json:"resourceID,omitempty"` + "`" + ` +} + +// {{ .Kind }}Status defines the config connector machine state of {{ .Kind }} +type {{ .Kind }}Status struct { + /* Conditions represent the latest available observations of the + object's current state. */ + Conditions []v1alpha1.Condition ` + "`" + `json:"conditions,omitempty"` + "`" + ` + + /* ObservedGeneration is the generation of the resource that was most recently observed by the Config Connector controller. If this is equal to metadata.generation, then that means that the current reported status reflects the most recent desired state of the resource. */ + // +optional + ObservedGeneration *int64 ` + "`" + `json:"observedGeneration,omitempty"` + "`" + ` + + /* A unique specifier for the {{ .Kind }} resource in GCP.*/ + // +optional + ExternalRef *string ` + "`" + `json:"externalRef,omitempty"` + "`" + ` + + /* ObservedState is the state of the resource as most recently observed in GCP. */ + // +optional + ObservedState *{{ .Kind }}ObservedState ` + "`" + `json:"observedState,omitempty"` + "`" + ` +} + +// {{ .Kind }}Spec defines the desired state of {{ .Kind }} +{{- if .KindProtoTag }} +// +kcc:proto={{ .KindProtoTag }} +{{- end }} +type {{ .Kind }}ObservedState struct { +} + + +// +genclient +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +// +kubebuilder:subresource:status +// +kubebuilder:metadata:labels="cnrm.cloud.google.com/managed-by-kcc=true";"cnrm.cloud.google.com/system=true" +// +kubebuilder:printcolumn:name="Ready",JSONPath=".status.conditions[?(@.type=='Ready')].status",type="string",description="When 'True', the most recent reconcile of the resource succeeded" +// +kubebuilder:printcolumn:name="Status",JSONPath=".status.conditions[?(@.type=='Ready')].reason",type="string",description="The reason for the value in 'Ready'" +// +kubebuilder:printcolumn:name="Status Age",JSONPath=".status.conditions[?(@.type=='Ready')].lastTransitionTime",type="date",description="The last transition time for the value in 'Status'" + +// {{ .Kind }} is the Schema for the {{ .Kind }} API +// +k8s:openapi-gen=true +type {{ .Kind }} struct { + metav1.TypeMeta ` + "`" + `json:",inline"` + "`" + ` + metav1.ObjectMeta ` + "`" + `json:"metadata,omitempty"` + "`" + ` + + Spec {{ .Kind }}Spec ` + "`" + `json:"spec,omitempty"` + "`" + ` + Status {{ .Kind }}Status ` + "`" + `json:"status,omitempty"` + "`" + ` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +// {{ .Kind }}List contains a list of {{ .Kind }} +type {{ .Kind }}List struct { + metav1.TypeMeta ` + "`" + `json:",inline"` + "`" + ` + metav1.ListMeta ` + "`" + `json:"metadata,omitempty"` + "`" + ` + Items []{{ .Kind }} ` + "`" + `json:"items"` + "`" + ` +} + +func init() { + SchemeBuilder.Register(&{{ .Kind }}{}, &{{ .Kind }}List{}) +} +`