diff --git a/cmd/kubebuilder/api.go b/cmd/kubebuilder/api.go index f0f45982d03..de7cbe53870 100644 --- a/cmd/kubebuilder/api.go +++ b/cmd/kubebuilder/api.go @@ -28,11 +28,11 @@ import ( "github.com/spf13/cobra" flag "github.com/spf13/pflag" - "sigs.k8s.io/controller-tools/pkg/scaffold" - "sigs.k8s.io/controller-tools/pkg/scaffold/controller" - "sigs.k8s.io/controller-tools/pkg/scaffold/input" - "sigs.k8s.io/controller-tools/pkg/scaffold/resource" "sigs.k8s.io/kubebuilder/cmd/kubebuilder/util" + "sigs.k8s.io/kubebuilder/pkg/scaffold" + "sigs.k8s.io/kubebuilder/pkg/scaffold/controller" + "sigs.k8s.io/kubebuilder/pkg/scaffold/input" + "sigs.k8s.io/kubebuilder/pkg/scaffold/resource" ) type apiOptions struct { diff --git a/cmd/kubebuilder/init_project.go b/cmd/kubebuilder/init_project.go index 77913854b57..09faaafb5be 100644 --- a/cmd/kubebuilder/init_project.go +++ b/cmd/kubebuilder/init_project.go @@ -29,11 +29,11 @@ import ( "github.com/spf13/cobra" flag "github.com/spf13/pflag" - "sigs.k8s.io/controller-tools/pkg/scaffold" - "sigs.k8s.io/controller-tools/pkg/scaffold/input" - "sigs.k8s.io/controller-tools/pkg/scaffold/manager" - "sigs.k8s.io/controller-tools/pkg/scaffold/project" "sigs.k8s.io/kubebuilder/cmd/kubebuilder/util" + "sigs.k8s.io/kubebuilder/pkg/scaffold" + "sigs.k8s.io/kubebuilder/pkg/scaffold/input" + "sigs.k8s.io/kubebuilder/pkg/scaffold/manager" + "sigs.k8s.io/kubebuilder/pkg/scaffold/project" ) func newInitProjectCmd() *cobra.Command { diff --git a/cmd/kubebuilder/vendor_update.go b/cmd/kubebuilder/vendor_update.go index 793b1ef10ac..07f2a0684fb 100644 --- a/cmd/kubebuilder/vendor_update.go +++ b/cmd/kubebuilder/vendor_update.go @@ -20,9 +20,9 @@ import ( "log" "github.com/spf13/cobra" - "sigs.k8s.io/controller-tools/pkg/scaffold" - "sigs.k8s.io/controller-tools/pkg/scaffold/input" - "sigs.k8s.io/controller-tools/pkg/scaffold/project" + "sigs.k8s.io/kubebuilder/pkg/scaffold" + "sigs.k8s.io/kubebuilder/pkg/scaffold/input" + "sigs.k8s.io/kubebuilder/pkg/scaffold/project" ) func newVendorUpdateCmd() *cobra.Command { diff --git a/cmd/kubebuilder/webhook.go b/cmd/kubebuilder/webhook.go index 7a2c1c3e86d..e2761262f88 100644 --- a/cmd/kubebuilder/webhook.go +++ b/cmd/kubebuilder/webhook.go @@ -27,10 +27,10 @@ import ( "k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/runtime/schema" - "sigs.k8s.io/controller-tools/pkg/scaffold" - "sigs.k8s.io/controller-tools/pkg/scaffold/input" - "sigs.k8s.io/controller-tools/pkg/scaffold/resource" - "sigs.k8s.io/controller-tools/pkg/scaffold/webhook" + "sigs.k8s.io/kubebuilder/pkg/scaffold" + "sigs.k8s.io/kubebuilder/pkg/scaffold/input" + "sigs.k8s.io/kubebuilder/pkg/scaffold/resource" + "sigs.k8s.io/kubebuilder/pkg/scaffold/webhook" ) func newWebhookCmd() *cobra.Command { diff --git a/pkg/scaffold/controller/add.go b/pkg/scaffold/controller/add.go new file mode 100644 index 00000000000..a1716c27153 --- /dev/null +++ b/pkg/scaffold/controller/add.go @@ -0,0 +1,60 @@ +/* +Copyright 2018 The Kubernetes 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 controller + +import ( + "fmt" + "path/filepath" + "strings" + + "sigs.k8s.io/kubebuilder/pkg/scaffold/input" + "sigs.k8s.io/kubebuilder/pkg/scaffold/resource" +) + +var _ input.File = &AddController{} + +// AddController scaffolds adds a new Controller. +type AddController struct { + input.Input + + // Resource is a resource in the API group + Resource *resource.Resource +} + +// GetInput implements input.File +func (a *AddController) GetInput() (input.Input, error) { + if a.Path == "" { + a.Path = filepath.Join("pkg", "controller", fmt.Sprintf( + "add_%s.go", strings.ToLower(a.Resource.Kind))) + } + a.TemplateBody = addControllerTemplate + return a.Input, nil +} + +var addControllerTemplate = `{{ .Boilerplate }} + +package controller + +import ( + "{{ .Repo }}/pkg/controller/{{ lower .Resource.Kind }}" +) + +func init() { + // AddToManagerFuncs is a list of functions to create controllers and add them to a manager. + AddToManagerFuncs = append(AddToManagerFuncs, {{ lower .Resource.Kind }}.Add) +} +` diff --git a/pkg/scaffold/controller/controller.go b/pkg/scaffold/controller/controller.go new file mode 100644 index 00000000000..a9f978a5596 --- /dev/null +++ b/pkg/scaffold/controller/controller.go @@ -0,0 +1,271 @@ +/* +Copyright 2018 The Kubernetes 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 controller + +import ( + "fmt" + "os" + "path" + "path/filepath" + "strings" + + "github.com/markbates/inflect" + "sigs.k8s.io/kubebuilder/pkg/scaffold/input" + "sigs.k8s.io/kubebuilder/pkg/scaffold/resource" +) + +// Controller scaffolds a Controller for a Resource +type Controller struct { + input.Input + + // Resource is the Resource to make the Controller for + Resource *resource.Resource + + // ResourcePackage is the package of the Resource + ResourcePackage string + + // Plural is the plural lowercase of kind + Plural string + + // Is the Group + "." + Domain for the Resource + GroupDomain string +} + +// GetInput implements input.File +func (a *Controller) GetInput() (input.Input, error) { + // Use the k8s.io/api package for core resources + coreGroups := map[string]string{ + "apps": "", + "admissionregistration": "k8s.io", + "apiextensions": "k8s.io", + "authentication": "k8s.io", + "autoscaling": "", + "batch": "", + "certificates": "k8s.io", + "core": "", + "extensions": "", + "metrics": "k8s.io", + "policy": "", + "rbac.authorization": "k8s.io", + "storage": "k8s.io", + } + + a.ResourcePackage, a.GroupDomain = getResourceInfo(coreGroups, a.Resource, a.Input) + + if a.Plural == "" { + rs := inflect.NewDefaultRuleset() + a.Plural = rs.Pluralize(strings.ToLower(a.Resource.Kind)) + } + + if a.Path == "" { + a.Path = filepath.Join("pkg", "controller", + strings.ToLower(a.Resource.Kind), + strings.ToLower(a.Resource.Kind)+"_controller.go") + } + a.TemplateBody = controllerTemplate + a.Input.IfExistsAction = input.Error + return a.Input, nil +} + +func getResourceInfo(coreGroups map[string]string, r *resource.Resource, in input.Input) (resourcePackage, groupDomain string) { + resourcePath := filepath.Join("pkg", "apis", r.Group, r.Version, + fmt.Sprintf("%s_types.go", strings.ToLower(r.Kind))) + if _, err := os.Stat(resourcePath); os.IsNotExist(err) { + if domain, found := coreGroups[r.Group]; found { + resourcePackage := path.Join("k8s.io", "api") + groupDomain = r.Group + if domain != "" { + groupDomain = r.Group + "." + domain + } + return resourcePackage, groupDomain + } + // TODO: need to support '--resource-pkg-path' flag for specifying resourcePath + } + return path.Join(in.Repo, "pkg", "apis"), r.Group + "." + in.Domain +} + +var controllerTemplate = `{{ .Boilerplate }} + +package {{ lower .Resource.Kind }} + +import ( +{{ if .Resource.CreateExampleReconcileBody }} "context" + "log" + "reflect" + + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" + {{ .Resource.Group}}{{ .Resource.Version }} "{{ .ResourcePackage }}/{{ .Resource.Group}}/{{ .Resource.Version }}" +{{ else }} "context" + + appsv1 "k8s.io/api/apps/v1" + "k8s.io/apimachinery/pkg/api/errors" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "sigs.k8s.io/controller-runtime/pkg/source" + {{ .Resource.Group}}{{ .Resource.Version }} "{{ .ResourcePackage }}/{{ .Resource.Group}}/{{ .Resource.Version }}" +{{ end -}} +) + +/** +* USER ACTION REQUIRED: This is a scaffold file intended for the user to modify with their own Controller +* business logic. Delete these comments after modifying this file.* + */ + +// Add creates a new {{ .Resource.Kind }} Controller and adds it to the Manager with default RBAC. The Manager will set fields on the Controller +// and Start it when the Manager is Started. +// USER ACTION REQUIRED: update cmd/manager/main.go to call this {{ .Resource.Group}}.Add(mgr) to install this Controller +func Add(mgr manager.Manager) error { + return add(mgr, newReconciler(mgr)) +} + +// newReconciler returns a new reconcile.Reconciler +func newReconciler(mgr manager.Manager) reconcile.Reconciler { + return &Reconcile{{ .Resource.Kind }}{Client: mgr.GetClient(), scheme: mgr.GetScheme()} +} + +// add adds a new Controller to mgr with r as the reconcile.Reconciler +func add(mgr manager.Manager, r reconcile.Reconciler) error { + // Create a new controller + c, err := controller.New("{{ lower .Resource.Kind }}-controller", mgr, controller.Options{Reconciler: r}) + if err != nil { + return err + } + + // Watch for changes to {{ .Resource.Kind }} + err = c.Watch(&source.Kind{Type: &{{ .Resource.Group}}{{ .Resource.Version }}.{{ .Resource.Kind }}{}}, &handler.EnqueueRequestForObject{}) + if err != nil { + return err + } + + // TODO(user): Modify this to be the types you create + // Uncomment watch a Deployment created by {{ .Resource.Kind }} - change this for objects you create + err = c.Watch(&source.Kind{Type: &appsv1.Deployment{}}, &handler.EnqueueRequestForOwner{ + IsController: true, + OwnerType: &{{ .Resource.Group}}{{ .Resource.Version }}.{{ .Resource.Kind }}{}, + }) + if err != nil { + return err + } + + return nil +} + +var _ reconcile.Reconciler = &Reconcile{{ .Resource.Kind }}{} + +// Reconcile{{ .Resource.Kind }} reconciles a {{ .Resource.Kind }} object +type Reconcile{{ .Resource.Kind }} struct { + client.Client + scheme *runtime.Scheme +} + +// Reconcile reads that state of the cluster for a {{ .Resource.Kind }} object and makes changes based on the state read +// and what is in the {{ .Resource.Kind }}.Spec +// TODO(user): Modify this Reconcile function to implement your Controller logic. The scaffolding writes +// a Deployment as an example +{{ if .Resource.CreateExampleReconcileBody -}} +// Automatically generate RBAC rules to allow the Controller to read and write Deployments +// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete +{{ end -}} +// +kubebuilder:rbac:groups={{.GroupDomain}},resources={{ .Plural }},verbs=get;list;watch;create;update;patch;delete +func (r *Reconcile{{ .Resource.Kind }}) Reconcile(request reconcile.Request) (reconcile.Result, error) { + // Fetch the {{ .Resource.Kind }} instance + instance := &{{ .Resource.Group}}{{ .Resource.Version }}.{{ .Resource.Kind }}{} + err := r.Get(context.TODO(), request.NamespacedName, instance) + if err != nil { + if errors.IsNotFound(err) { + // Object not found, return. Created objects are automatically garbage collected. + // For additional cleanup logic use finalizers. + return reconcile.Result{}, nil + } + // Error reading the object - requeue the request. + return reconcile.Result{}, err + } + + {{ if .Resource.CreateExampleReconcileBody -}} + // TODO(user): Change this to be the object type created by your controller + // Define the desired Deployment object + deploy := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: instance.Name + "-deployment", + Namespace: {{ if .Resource.Namespaced}}instance.Namespace{{ else }}"default"{{ end }}, + }, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{"deployment": instance.Name + "-deployment"}, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"deployment": instance.Name + "-deployment"}}, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "nginx", + Image: "nginx", + }, + }, + }, + }, + }, + } + if err := controllerutil.SetControllerReference(instance, deploy, r.scheme); err != nil { + return reconcile.Result{}, err + } + + // TODO(user): Change this for the object type created by your controller + // Check if the Deployment already exists + found := &appsv1.Deployment{} + err = r.Get(context.TODO(), types.NamespacedName{Name: deploy.Name, Namespace: deploy.Namespace}, found) + if err != nil && errors.IsNotFound(err) { + log.Printf("Creating Deployment %s/%s\n", deploy.Namespace, deploy.Name) + err = r.Create(context.TODO(), deploy) + if err != nil { + return reconcile.Result{}, err + } + } else if err != nil { + return reconcile.Result{}, err + } + + // TODO(user): Change this for the object type created by your controller + // Update the found object and write the result back if there are any changes + if !reflect.DeepEqual(deploy.Spec, found.Spec) { + found.Spec = deploy.Spec + log.Printf("Updating Deployment %s/%s\n", deploy.Namespace, deploy.Name) + err = r.Update(context.TODO(), found) + if err != nil { + return reconcile.Result{}, err + } + } + {{ end -}} + + return reconcile.Result{}, nil +} +` diff --git a/pkg/scaffold/controller/controller_suite_test.go b/pkg/scaffold/controller/controller_suite_test.go new file mode 100644 index 00000000000..5edb98de7df --- /dev/null +++ b/pkg/scaffold/controller/controller_suite_test.go @@ -0,0 +1,13 @@ +package controller_test + +import ( + "testing" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +func TestController(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Controller Suite") +} diff --git a/pkg/scaffold/controller/controller_test.go b/pkg/scaffold/controller/controller_test.go new file mode 100644 index 00000000000..534c73e0fb7 --- /dev/null +++ b/pkg/scaffold/controller/controller_test.go @@ -0,0 +1,64 @@ +package controller + +import ( + "fmt" + "path/filepath" + "strings" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + "sigs.k8s.io/kubebuilder/pkg/scaffold/input" + "sigs.k8s.io/kubebuilder/pkg/scaffold/resource" + "sigs.k8s.io/kubebuilder/pkg/scaffold/scaffoldtest" +) + +var _ = Describe("Controller", func() { + resources := []*resource.Resource{ + {Group: "crew", Version: "v1", Kind: "FirstMate", Namespaced: true, CreateExampleReconcileBody: true}, + {Group: "ship", Version: "v1beta1", Kind: "Frigate", Namespaced: true, CreateExampleReconcileBody: false}, + {Group: "creatures", Version: "v2alpha1", Kind: "Kraken", Namespaced: false, CreateExampleReconcileBody: false}, + {Group: "core", Version: "v1", Kind: "Namespace", Namespaced: false, CreateExampleReconcileBody: false}, + } + + for i := range resources { + r := resources[i] + Describe(fmt.Sprintf("scaffolding Controller %s", r.Kind), func() { + files := []struct { + instance input.File + file string + }{ + { + file: filepath.Join("pkg", "controller", + fmt.Sprintf("add_%s.go", strings.ToLower(r.Kind))), + instance: &AddController{Resource: r}, + }, + { + file: filepath.Join("pkg", "controller", strings.ToLower(r.Kind), + strings.ToLower(r.Kind)+"_controller.go"), + instance: &Controller{Resource: r}, + }, + { + file: filepath.Join("pkg", "controller", + strings.ToLower(r.Kind), strings.ToLower(r.Kind)+"_controller_suite_test.go"), + instance: &SuiteTest{Resource: r}, + }, + { + file: filepath.Join("pkg", "controller", + strings.ToLower(r.Kind), strings.ToLower(r.Kind)+"_controller_test.go"), + instance: &Test{Resource: r}, + }, + } + + for j := range files { + f := files[j] + Context(f.file, func() { + It("should write a file matching the golden file", func() { + s, result := scaffoldtest.NewTestScaffold(f.file, f.file) + Expect(s.Execute(scaffoldtest.Options(), f.instance)).To(Succeed()) + Expect(result.Actual.String()).To(Equal(result.Golden), result.Actual.String()) + }) + }) + } + }) + } +}) diff --git a/pkg/scaffold/controller/controllersuitetest.go b/pkg/scaffold/controller/controllersuitetest.go new file mode 100644 index 00000000000..cbefc752d1c --- /dev/null +++ b/pkg/scaffold/controller/controllersuitetest.go @@ -0,0 +1,106 @@ +/* +Copyright 2018 The Kubernetes 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 controller + +import ( + "path/filepath" + "strings" + + "sigs.k8s.io/kubebuilder/pkg/scaffold/input" + "sigs.k8s.io/kubebuilder/pkg/scaffold/resource" +) + +// SuiteTest scaffolds a SuiteTest +type SuiteTest struct { + input.Input + + // Resource is the Resource to make the Controller for + Resource *resource.Resource +} + +// GetInput implements input.File +func (a *SuiteTest) GetInput() (input.Input, error) { + if a.Path == "" { + a.Path = filepath.Join("pkg", "controller", + strings.ToLower(a.Resource.Kind), strings.ToLower(a.Resource.Kind)+"_controller_suite_test.go") + } + a.TemplateBody = controllerSuiteTestTemplate + return a.Input, nil +} + +var controllerSuiteTestTemplate = `{{ .Boilerplate }} + +package {{ lower .Resource.Kind }} + +import ( + "log" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/onsi/gomega" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/envtest" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + "{{ .Repo }}/pkg/apis" +) + +var cfg *rest.Config + +func TestMain(m *testing.M) { + t := &envtest.Environment{ + CRDDirectoryPaths: []string{filepath.Join("..", "..", "..", "config", "crds")}, + } + apis.AddToScheme(scheme.Scheme) + + var err error + if cfg, err = t.Start(); err != nil { + log.Fatal(err) + } + + code := m.Run() + t.Stop() + os.Exit(code) +} + +// SetupTestReconcile returns a reconcile.Reconcile implementation that delegates to inner and +// writes the request to requests after Reconcile is finished. +func SetupTestReconcile(inner reconcile.Reconciler) (reconcile.Reconciler, chan reconcile.Request) { + requests := make(chan reconcile.Request) + fn := reconcile.Func(func(req reconcile.Request) (reconcile.Result, error) { + result, err := inner.Reconcile(req) + requests <- req + return result, err + }) + return fn, requests +} + +// StartTestManager adds recFn +func StartTestManager(mgr manager.Manager, g *gomega.GomegaWithT) (chan struct{}, *sync.WaitGroup) { + stop := make(chan struct{}) + wg := &sync.WaitGroup{} + go func() { + wg.Add(1) + g.Expect(mgr.Start(stop)).NotTo(gomega.HaveOccurred()) + wg.Done() + }() + return stop, wg +} +` diff --git a/pkg/scaffold/controller/controllertest.go b/pkg/scaffold/controller/controllertest.go new file mode 100644 index 00000000000..c87114a12a3 --- /dev/null +++ b/pkg/scaffold/controller/controllertest.go @@ -0,0 +1,144 @@ +/* +Copyright 2018 The Kubernetes 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 controller + +import ( + "path/filepath" + "strings" + + "sigs.k8s.io/kubebuilder/pkg/scaffold/input" + "sigs.k8s.io/kubebuilder/pkg/scaffold/resource" +) + +// Test scaffolds a Controller Test +type Test struct { + input.Input + + // Resource is the Resource to make the Controller for + Resource *resource.Resource + + // ResourcePackage is the package of the Resource + ResourcePackage string +} + +// GetInput implements input.File +func (a *Test) GetInput() (input.Input, error) { + if a.Path == "" { + a.Path = filepath.Join("pkg", "controller", + strings.ToLower(a.Resource.Kind), strings.ToLower(a.Resource.Kind)+"_controller_test.go") + } + + // Use the k8s.io/api package for core resources + coreGroups := map[string]string{ + "apps": "", + "admissionregistration": "k8s.io", + "apiextensions": "k8s.io", + "authentication": "k8s.io", + "autoscaling": "", + "batch": "", + "certificates": "k8s.io", + "core": "", + "extensions": "", + "metrics": "k8s.io", + "policy": "", + "rbac.authorization": "k8s.io", + "storage": "k8s.io", + } + + a.ResourcePackage, _ = getResourceInfo(coreGroups, a.Resource, a.Input) + + a.TemplateBody = controllerTestTemplate + a.Input.IfExistsAction = input.Error + return a.Input, nil +} + +var controllerTestTemplate = `{{ .Boilerplate }} + +package {{ lower .Resource.Kind }} + +import ( + "testing" + "time" + + "github.com/onsi/gomega" + "golang.org/x/net/context" + {{ if .Resource.CreateExampleReconcileBody -}} + appsv1 "k8s.io/api/apps/v1" + {{ end -}} + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/manager" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + {{ .Resource.Group}}{{ .Resource.Version }} "{{ .ResourcePackage }}/{{ .Resource.Group}}/{{ .Resource.Version }}" +) + +var c client.Client + +var expectedRequest = reconcile.Request{NamespacedName: types.NamespacedName{Name: "foo"{{ if .Resource.Namespaced }}, Namespace: "default"{{end}}}} +{{ if .Resource.CreateExampleReconcileBody }}var depKey = types.NamespacedName{Name: "foo-deployment", Namespace: "default"} +{{ end }} +const timeout = time.Second * 5 + +func TestReconcile(t *testing.T) { + g := gomega.NewGomegaWithT(t) + instance := &{{ .Resource.Group }}{{ .Resource.Version }}.{{ .Resource.Kind }}{ObjectMeta: metav1.ObjectMeta{Name: "foo"{{ if .Resource.Namespaced }}, Namespace: "default"{{end}}}} + + // Setup the Manager and Controller. Wrap the Controller Reconcile function so it writes each request to a + // channel when it is finished. + mgr, err := manager.New(cfg, manager.Options{}) + g.Expect(err).NotTo(gomega.HaveOccurred()) + c = mgr.GetClient() + + recFn, requests := SetupTestReconcile(newReconciler(mgr)) + g.Expect(add(mgr, recFn)).NotTo(gomega.HaveOccurred()) + + stopMgr, mgrStopped := StartTestManager(mgr, g) + + defer func() { + close(stopMgr) + mgrStopped.Wait() + }() + + // Create the {{ .Resource.Kind }} object and expect the Reconcile{{ if .Resource.CreateExampleReconcileBody }} and Deployment to be created{{ end }} + err = c.Create(context.TODO(), instance) + // The instance object may not be a valid object because it might be missing some required fields. + // Please modify the instance object by adding required fields and then remove the following if statement. + if apierrors.IsInvalid(err) { + t.Logf("failed to create object, got an invalid object error: %v", err) + return + } + g.Expect(err).NotTo(gomega.HaveOccurred()) + defer c.Delete(context.TODO(), instance) + g.Eventually(requests, timeout).Should(gomega.Receive(gomega.Equal(expectedRequest))) +{{ if .Resource.CreateExampleReconcileBody }} + deploy := &appsv1.Deployment{} + g.Eventually(func() error { return c.Get(context.TODO(), depKey, deploy) }, timeout). + Should(gomega.Succeed()) + + // Delete the Deployment and expect Reconcile to be called for Deployment deletion + g.Expect(c.Delete(context.TODO(), deploy)).NotTo(gomega.HaveOccurred()) + g.Eventually(requests, timeout).Should(gomega.Receive(gomega.Equal(expectedRequest))) + g.Eventually(func() error { return c.Get(context.TODO(), depKey, deploy) }, timeout). + Should(gomega.Succeed()) + + // Manually delete Deployment since GC isn't enabled in the test control plane + g.Expect(c.Delete(context.TODO(), deploy)).To(gomega.Succeed()) +{{ end }} +} +` diff --git a/pkg/scaffold/doc.go b/pkg/scaffold/doc.go new file mode 100644 index 00000000000..583a19eb4ea --- /dev/null +++ b/pkg/scaffold/doc.go @@ -0,0 +1,18 @@ +/* +Copyright 2018 The Kubernetes 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 scaffold contains libraries for scaffolding code to use with controller-runtime +package scaffold diff --git a/pkg/scaffold/input/input.go b/pkg/scaffold/input/input.go new file mode 100644 index 00000000000..3cb2f7e007e --- /dev/null +++ b/pkg/scaffold/input/input.go @@ -0,0 +1,172 @@ +/* +Copyright 2018 The Kubernetes 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 input + +// IfExistsAction determines what to do if the scaffold file already exists +type IfExistsAction int + +const ( + // Skip skips the file and moves to the next one + Skip IfExistsAction = iota + + // Error returns an error and stops processing + Error + + // Overwrite truncates and overwrites the existing file + Overwrite +) + +// Input is the input for scaffoldig a file +type Input struct { + // Path is the file to write + Path string + + // IfExistsAction determines what to do if the file exists + IfExistsAction IfExistsAction + + // TemplateBody is the template body to execute + TemplateBody string + + // Boilerplate is the contents of a Boilerplate go header file + Boilerplate string + + // BoilerplatePath is the path to a Boilerplate go header file + BoilerplatePath string + + // Version is the project version + Version string + + // Domain is the domain for the APIs + Domain string + + // Repo is the go project package + Repo string + + // ProjectPath is the relative path to the project root + ProjectPath string +} + +// Domain allows a domain to be set on an object +type Domain interface { + // SetDomain sets the domain + SetDomain(string) +} + +// SetDomain sets the domain +func (i *Input) SetDomain(d string) { + if i.Domain == "" { + i.Domain = d + } +} + +// Repo allows a repo to be set on an object +type Repo interface { + // SetRepo sets the repo + SetRepo(string) +} + +// SetRepo sets the repo +func (i *Input) SetRepo(r string) { + if i.Repo == "" { + i.Repo = r + } +} + +// Boilerplate allows boilerplate text to be set on an object +type Boilerplate interface { + // SetBoilerplate sets the boilerplate text + SetBoilerplate(string) +} + +// SetBoilerplate sets the boilerplate text +func (i *Input) SetBoilerplate(b string) { + if i.Boilerplate == "" { + i.Boilerplate = b + } +} + +// BoilerplatePath allows boilerplate file path to be set on an object +type BoilerplatePath interface { + // SetBoilerplatePath sets the boilerplate file path + SetBoilerplatePath(string) +} + +// SetBoilerplatePath sets the boilerplate file path +func (i *Input) SetBoilerplatePath(bp string) { + if i.BoilerplatePath == "" { + i.BoilerplatePath = bp + } +} + +// Version allows the project version to be set on an object +type Version interface { + // SetVersion sets the project version + SetVersion(string) +} + +// SetVersion sets the project version +func (i *Input) SetVersion(v string) { + if i.Version == "" { + i.Version = v + } +} + +// ProjecPath allows the project path to be set on an object +type ProjecPath interface { + // SetProjectPath sets the project file location + SetProjectPath(string) +} + +// SetProjectPath sets the project path +func (i *Input) SetProjectPath(p string) { + if i.ProjectPath == "" { + i.ProjectPath = p + } +} + +// File is a scaffoldable file +type File interface { + // GetInput returns the Input for creating a scaffold file + GetInput() (Input, error) +} + +// Validate validates input +type Validate interface { + // Validate returns true if the template has valid values + Validate() error +} + +// Options are the options for executing scaffold templates +type Options struct { + // BoilerplatePath is the path to the boilerplate file + BoilerplatePath string + + // Path is the path to the project + ProjectPath string +} + +// ProjectFile is deserialized into a PROJECT file +type ProjectFile struct { + // Version is the project version - defaults to "2" + Version string `yaml:"version,omitempty"` + + // Domain is the domain associated with the project and used for API groups + Domain string `yaml:"domain,omitempty"` + + // Repo is the go package name of the project root + Repo string `yaml:"repo,omitempty"` +} diff --git a/pkg/scaffold/input/input_suite_test.go b/pkg/scaffold/input/input_suite_test.go new file mode 100644 index 00000000000..bd3d38bc89b --- /dev/null +++ b/pkg/scaffold/input/input_suite_test.go @@ -0,0 +1,13 @@ +package input_test + +import ( + "testing" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +func TestInput(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Input Suite") +} diff --git a/pkg/scaffold/input/input_test.go b/pkg/scaffold/input/input_test.go new file mode 100644 index 00000000000..1d4b7c3c5e6 --- /dev/null +++ b/pkg/scaffold/input/input_test.go @@ -0,0 +1,9 @@ +package input_test + +import ( + . "github.com/onsi/ginkgo" +) + +var _ = Describe("Input", func() { + +}) diff --git a/pkg/scaffold/manager/apis.go b/pkg/scaffold/manager/apis.go new file mode 100644 index 00000000000..a50215a59ac --- /dev/null +++ b/pkg/scaffold/manager/apis.go @@ -0,0 +1,79 @@ +/* +Copyright 2018 The Kubernetes 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 manager + +import ( + "fmt" + "path/filepath" + "strings" + + "sigs.k8s.io/kubebuilder/pkg/scaffold/input" +) + +var _ input.File = &APIs{} + +// APIs scaffolds a apis.go to register types with a Scheme +type APIs struct { + input.Input + + // Comments is a list of comments to add to the apis.go + Comments []string +} + +var deepCopy = strings.Join([]string{ + "//go:generate go run", + "../../vendor/k8s.io/code-generator/cmd/deepcopy-gen/main.go", + "-O zz_generated.deepcopy", + "-i ./..."}, " ") + +// GetInput implements input.File +func (a *APIs) GetInput() (input.Input, error) { + if a.Path == "" { + a.Path = filepath.Join("pkg", "apis", "apis.go") + } + + b, err := filepath.Rel(filepath.Join(a.Input.ProjectPath, "pkg", "apis"), a.BoilerplatePath) + if err != nil { + return input.Input{}, err + } + if len(a.Comments) == 0 { + a.Comments = append(a.Comments, + "// Generate deepcopy for apis", fmt.Sprintf("%s -h %s", deepCopy, b)) + } + a.TemplateBody = apisTemplate + return a.Input, nil +} + +var apisTemplate = `{{ .Boilerplate }} + +{{ range $line := .Comments }}{{ $line }} +{{ end }} +// Package apis contains Kubernetes API groups. +package apis + +import ( + "k8s.io/apimachinery/pkg/runtime" +) + +// AddToSchemes may be used to add all resources defined in the project to a Scheme +var AddToSchemes runtime.SchemeBuilder + +// AddToScheme adds all Resources to the Scheme +func AddToScheme(s *runtime.Scheme) error { + return AddToSchemes.AddToScheme(s) +} +` diff --git a/pkg/scaffold/manager/cmd.go b/pkg/scaffold/manager/cmd.go new file mode 100644 index 00000000000..25ad3b79039 --- /dev/null +++ b/pkg/scaffold/manager/cmd.go @@ -0,0 +1,108 @@ +/* +Copyright 2018 The Kubernetes 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 manager + +import ( + "path/filepath" + + "sigs.k8s.io/kubebuilder/pkg/scaffold/input" +) + +var _ input.File = &Cmd{} + +// Cmd scaffolds a manager.go to run Controllers +type Cmd struct { + input.Input +} + +// GetInput implements input.File +func (a *Cmd) GetInput() (input.Input, error) { + if a.Path == "" { + a.Path = filepath.Join("cmd", "manager", "main.go") + } + a.TemplateBody = cmdTemplate + return a.Input, nil +} + +var cmdTemplate = `{{ .Boilerplate }} + +package main + +import ( + "flag" + "os" + + _ "k8s.io/client-go/plugin/pkg/client/auth/gcp" + "sigs.k8s.io/controller-runtime/pkg/client/config" + "sigs.k8s.io/controller-runtime/pkg/manager" + logf "sigs.k8s.io/controller-runtime/pkg/runtime/log" + "sigs.k8s.io/controller-runtime/pkg/runtime/signals" + "{{ .Repo }}/pkg/apis" + "{{ .Repo }}/pkg/controller" + "{{ .Repo }}/pkg/webhook" +) + +func main() { + logf.SetLogger(logf.ZapLogger(false)) + log := logf.Log.WithName("entrypoint") + + // Get a config to talk to the apiserver + log.Info("setting up client for manager") + cfg, err := config.GetConfig() + if err != nil { + log.Error(err, "unable to set up client config") + os.Exit(1) + } + + // Create a new Cmd to provide shared dependencies and start components + log.Info("setting up manager") + mgr, err := manager.New(cfg, manager.Options{}) + if err != nil { + log.Error(err, "unable to set up overall controller manager") + os.Exit(1) + } + + log.Info("Registering Components.") + + // Setup Scheme for all resources + log.Info("setting up scheme") + if err := apis.AddToScheme(mgr.GetScheme()); err != nil { + log.Error(err, "unable add APIs to scheme") + os.Exit(1) + } + + // Setup all Controllers + log.Info("Setting up controller") + if err := controller.AddToManager(mgr); err != nil { + log.Error(err, "unable to register controllers to the manager") + os.Exit(1) + } + + log.Info("setting up webhooks") + if err := webhook.AddToManager(mgr); err != nil { + log.Error(err, "unable to register webhooks to the manager") + os.Exit(1) + } + + // Start the Cmd + log.Info("Starting the Cmd.") + if err := mgr.Start(signals.SetupSignalHandler()); err != nil { + log.Error(err, "unable to run the manager") + os.Exit(1) + } +} +` diff --git a/pkg/scaffold/manager/config.go b/pkg/scaffold/manager/config.go new file mode 100644 index 00000000000..63c7f626115 --- /dev/null +++ b/pkg/scaffold/manager/config.go @@ -0,0 +1,125 @@ +/* +Copyright 2018 The Kubernetes 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 manager + +import ( + "path/filepath" + + "sigs.k8s.io/kubebuilder/pkg/scaffold/input" +) + +var _ input.File = &Config{} + +// Config scaffolds yaml config for the manager. +type Config struct { + input.Input + // Image is controller manager image name + Image string +} + +// GetInput implements input.File +func (c *Config) GetInput() (input.Input, error) { + if c.Path == "" { + c.Path = filepath.Join("config", "manager", "manager.yaml") + } + c.TemplateBody = configTemplate + return c.Input, nil +} + +var configTemplate = `apiVersion: v1 +kind: Namespace +metadata: + labels: + controller-tools.k8s.io: "1.0" + name: system +--- +apiVersion: v1 +kind: Service +metadata: + name: controller-manager-service + namespace: system + labels: + control-plane: controller-manager + controller-tools.k8s.io: "1.0" +spec: + selector: + control-plane: controller-manager + controller-tools.k8s.io: "1.0" + ports: + - port: 443 +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: controller-manager + namespace: system + labels: + control-plane: controller-manager + controller-tools.k8s.io: "1.0" +spec: + selector: + matchLabels: + control-plane: controller-manager + controller-tools.k8s.io: "1.0" + serviceName: controller-manager-service + template: + metadata: + labels: + control-plane: controller-manager + controller-tools.k8s.io: "1.0" + spec: + containers: + - command: + - /root/manager + image: {{ .Image }} + imagePullPolicy: Always + name: manager + env: + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: SECRET_NAME + value: $(WEBHOOK_SECRET_NAME) + resources: + limits: + cpu: 100m + memory: 30Mi + requests: + cpu: 100m + memory: 20Mi + ports: + - containerPort: 9876 + name: webhook-server + protocol: TCP + volumeMounts: + - mountPath: /tmp/cert + name: cert + readOnly: true + terminationGracePeriodSeconds: 10 + volumes: + - name: cert + secret: + defaultMode: 420 + secretName: webhook-server-secret +--- +apiVersion: v1 +kind: Secret +metadata: + name: webhook-server-secret + namespace: system +` diff --git a/pkg/scaffold/manager/controller.go b/pkg/scaffold/manager/controller.go new file mode 100644 index 00000000000..d7bfe7ef36d --- /dev/null +++ b/pkg/scaffold/manager/controller.go @@ -0,0 +1,61 @@ +/* +Copyright 2018 The Kubernetes 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 manager + +import ( + "path/filepath" + + "sigs.k8s.io/kubebuilder/pkg/scaffold/input" +) + +var _ input.File = &Controller{} + +// Controller scaffolds a controller.go to add Controllers to a manager.Cmd +type Controller struct { + input.Input +} + +// GetInput implements input.File +func (c *Controller) GetInput() (input.Input, error) { + if c.Path == "" { + c.Path = filepath.Join("pkg", "controller", "controller.go") + } + c.TemplateBody = controllerTemplate + return c.Input, nil +} + +var controllerTemplate = `{{ .Boilerplate }} + +package controller + +import ( + "sigs.k8s.io/controller-runtime/pkg/manager" +) + +// AddToManagerFuncs is a list of functions to add all Controllers to the Manager +var AddToManagerFuncs []func(manager.Manager) error + +// AddToManager adds all Controllers to the Manager +func AddToManager(m manager.Manager) error { + for _, f := range AddToManagerFuncs { + if err := f(m); err != nil { + return err + } + } + return nil +} +` diff --git a/pkg/scaffold/manager/dockerfile.go b/pkg/scaffold/manager/dockerfile.go new file mode 100644 index 00000000000..7206082d5dd --- /dev/null +++ b/pkg/scaffold/manager/dockerfile.go @@ -0,0 +1,56 @@ +/* +Copyright 2018 The Kubernetes 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 manager + +import ( + "sigs.k8s.io/kubebuilder/pkg/scaffold/input" +) + +var _ input.File = &Dockerfile{} + +// Dockerfile scaffolds a Dockerfile for building a main +type Dockerfile struct { + input.Input +} + +// GetInput implements input.File +func (c *Dockerfile) GetInput() (input.Input, error) { + if c.Path == "" { + c.Path = "Dockerfile" + } + c.TemplateBody = dockerfileTemplate + return c.Input, nil +} + +var dockerfileTemplate = `# Build the manager binary +FROM golang:1.10.3 as builder + +# Copy in the go src +WORKDIR /go/src/{{ .Repo }} +COPY pkg/ pkg/ +COPY cmd/ cmd/ +COPY vendor/ vendor/ + +# Build +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o manager {{ .Repo }}/cmd/manager + +# Copy the controller-manager into a thin image +FROM ubuntu:latest +WORKDIR /root/ +COPY --from=builder /go/src/{{ .Repo }}/manager . +ENTRYPOINT ["./manager"] +` diff --git a/pkg/scaffold/manager/manager_suite_test.go b/pkg/scaffold/manager/manager_suite_test.go new file mode 100644 index 00000000000..8a0fb7dc37e --- /dev/null +++ b/pkg/scaffold/manager/manager_suite_test.go @@ -0,0 +1,13 @@ +package manager_test + +import ( + "testing" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +func TestManager(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Manager Suite") +} diff --git a/pkg/scaffold/manager/manager_test.go b/pkg/scaffold/manager/manager_test.go new file mode 100644 index 00000000000..ba9d36ff79e --- /dev/null +++ b/pkg/scaffold/manager/manager_test.go @@ -0,0 +1,65 @@ +package manager + +import ( + "fmt" + "path/filepath" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + "sigs.k8s.io/kubebuilder/pkg/scaffold/input" + "sigs.k8s.io/kubebuilder/pkg/scaffold/scaffoldtest" +) + +var _ = Describe("Manager", func() { + Describe(fmt.Sprintf("scaffolding Manager"), func() { + files := []struct { + instance input.File + file string + }{ + { + file: filepath.Join("pkg", "apis", "apis.go"), + instance: &APIs{}, + }, + { + file: filepath.Join("cmd", "manager", "main.go"), + instance: &Cmd{}, + }, + { + file: filepath.Join("config", "manager", "manager.yaml"), + instance: &Config{Image: "controller:latest"}, + }, + { + file: filepath.Join("pkg", "controller", "controller.go"), + instance: &Controller{}, + }, + { + file: filepath.Join("Dockerfile"), + instance: &Dockerfile{}, + }, + } + + for j := range files { + f := files[j] + Context(f.file, func() { + It("should write a file matching the golden file", func() { + s, result := scaffoldtest.NewTestScaffold(f.file, f.file) + Expect(s.Execute(scaffoldtest.Options(), f.instance)).To(Succeed()) + Expect(result.Actual.String()).To(Equal(result.Golden), result.Actual.String()) + }) + }) + } + }) + + Describe(fmt.Sprintf("scaffolding Manager"), func() { + Context("APIs", func() { + It("should return an error if the relative path cannot be calculated", func() { + instance := &APIs{} + s, _ := scaffoldtest.NewTestScaffold(filepath.Join("pkg", "apis", "apis.go"), "") + s.ProjectPath = "." + err := s.Execute(scaffoldtest.Options(), instance) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("Rel: can't make")) + }) + }) + }) +}) diff --git a/pkg/scaffold/manager/webhook.go b/pkg/scaffold/manager/webhook.go new file mode 100644 index 00000000000..ed7f89ff7b8 --- /dev/null +++ b/pkg/scaffold/manager/webhook.go @@ -0,0 +1,64 @@ +/* +Copyright 2018 The Kubernetes 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 manager + +import ( + "path/filepath" + + "sigs.k8s.io/kubebuilder/pkg/scaffold/input" +) + +var _ input.File = &Webhook{} + +// Webhook scaffolds a webhook.go to add webhook server(s) to a manager.Cmd +type Webhook struct { + input.Input +} + +// GetInput implements input.File +func (c *Webhook) GetInput() (input.Input, error) { + if c.Path == "" { + c.Path = filepath.Join("pkg", "webhook", "webhook.go") + } + c.TemplateBody = webhookTemplate + return c.Input, nil +} + +var webhookTemplate = `{{ .Boilerplate }} + +package webhook + +import ( + "sigs.k8s.io/controller-runtime/pkg/manager" +) + +// AddToManagerFuncs is a list of functions to add all Controllers to the Manager +var AddToManagerFuncs []func(manager.Manager) error + +// AddToManager adds all Controllers to the Manager +// +kubebuilder:rbac:groups=admissionregistration.k8s.io,resources=mutatingwebhookconfigurations;validatingwebhookconfigurations,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups="",resources=services,verbs=get;list;watch;create;update;patch;delete +func AddToManager(m manager.Manager) error { + for _, f := range AddToManagerFuncs { + if err := f(m); err != nil { + return err + } + } + return nil +} +` diff --git a/pkg/scaffold/project/boilerplate.go b/pkg/scaffold/project/boilerplate.go new file mode 100644 index 00000000000..0b75414f31b --- /dev/null +++ b/pkg/scaffold/project/boilerplate.go @@ -0,0 +1,86 @@ +/* +Copyright 2018 The Kubernetes 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 project + +import ( + "fmt" + "path/filepath" + "time" + + "sigs.k8s.io/kubebuilder/pkg/scaffold/input" +) + +var _ input.File = &Boilerplate{} + +// Boilerplate scaffolds a boilerplate header file. +type Boilerplate struct { + input.Input + + // License is the License type to write + License string + + // Owner is the copyright owner - e.g. "The Kubernetes Authors" + Owner string + + // Year is the copyright year + Year string +} + +// GetInput implements input.File +func (c *Boilerplate) GetInput() (input.Input, error) { + if c.Path == "" { + c.Path = filepath.Join("hack", "boilerplate.go.txt") + } + + // Boilerplate given + if len(c.Boilerplate) > 0 { + c.TemplateBody = c.Boilerplate + return c.Input, nil + } + + // Pick a template boilerplate option + if c.Year == "" { + c.Year = fmt.Sprintf("%v", time.Now().Year()) + } + switch c.License { + case "", "apache2": + c.TemplateBody = apache + case "none": + c.TemplateBody = none + } + return c.Input, nil +} + +var apache = `/* +{{ if .Owner }}Copyright {{ .Year }} {{ .Owner }}. +{{ end }} +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. +*/` + +var none = `/* +{{ if .Owner }}Copyright {{ .Year }} {{ .Owner }}{{ end }}. +*/` diff --git a/pkg/scaffold/project/gitignore.go b/pkg/scaffold/project/gitignore.go new file mode 100644 index 00000000000..02de1d5c18e --- /dev/null +++ b/pkg/scaffold/project/gitignore.go @@ -0,0 +1,64 @@ +/* +Copyright 2018 The Kubernetes 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 project + +import ( + "sigs.k8s.io/kubebuilder/pkg/scaffold/input" +) + +var _ input.File = &GitIgnore{} + +// GitIgnore scaffolds the .gitignore file +type GitIgnore struct { + input.Input +} + +// GetInput implements input.File +func (c *GitIgnore) GetInput() (input.Input, error) { + if c.Path == "" { + c.Path = ".gitignore" + } + c.TemplateBody = gitignoreTemplate + return c.Input, nil +} + +var gitignoreTemplate = ` +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib +bin + +# Test binary, build with ` + "`go test -c`" + ` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Kubernetes Generated files - skip generated files, except for vendored files + +zz_generated.* +!vendor/**/zz_generated.* + +# editor and IDE paraphernalia +.idea +*.swp +*.swo +*~ +` diff --git a/pkg/scaffold/project/gopkg.go b/pkg/scaffold/project/gopkg.go new file mode 100644 index 00000000000..699ed6bbb48 --- /dev/null +++ b/pkg/scaffold/project/gopkg.go @@ -0,0 +1,160 @@ +/* +Copyright 2018 The Kubernetes 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 project + +import ( + "bufio" + "bytes" + "fmt" + "io/ioutil" + "strings" + + "sigs.k8s.io/kubebuilder/pkg/scaffold/input" +) + +var _ input.File = &GopkgToml{} + +// GopkgToml writes a templatefile for Gopkg.toml +type GopkgToml struct { + input.Input + + // ManagedHeader is the header to write after the user owned pieces and before the managed parts of the Gopkg.toml + ManagedHeader string + + // DefaultGopkgUserContent is the default content to use for the user owned pieces + DefaultUserContent string + + // UserContent is the content to use for the user owned pieces + UserContent string + + // Stanzas are additional managed stanzas to add after the ManagedHeader + Stanzas []Stanza +} + +// Stanza is a single Gopkg.toml entry +type Stanza struct { + // Type will be between the'[[]]' e.g. override + Type string + + // Name will appear after 'name=' and does not include quotes e.g. k8s.io/client-go + Name string + // Version will appear after 'version=' and does not include quotes + Version string + + // Revision will appear after 'revsion=' and does not include quotes + Revision string +} + +// GetInput implements input.File +func (g *GopkgToml) GetInput() (input.Input, error) { + if g.Path == "" { + g.Path = "Gopkg.toml" + } + if g.ManagedHeader == "" { + g.ManagedHeader = DefaultGopkgHeader + } + + // Set the user content to be used if the Gopkg.toml doesn't exist + if g.DefaultUserContent == "" { + g.DefaultUserContent = DefaultGopkgUserContent + } + + // Set the user owned content from the last Gopkg.toml file - e.g. everything before the header + lastBytes, err := ioutil.ReadFile(g.Path) + if err != nil { + g.UserContent = g.DefaultUserContent + } else if g.UserContent, err = g.getUserContent(lastBytes); err != nil { + return input.Input{}, err + } + + g.Input.IfExistsAction = input.Overwrite + g.TemplateBody = depTemplate + return g.Input, nil +} + +func (g *GopkgToml) getUserContent(b []byte) (string, error) { + // Keep the users lines + scanner := bufio.NewScanner(bytes.NewReader(b)) + userLines := []string{} + found := false + for scanner.Scan() { + l := scanner.Text() + if l == g.ManagedHeader { + found = true + break + } + userLines = append(userLines, l) + } + + if !found { + return "", fmt.Errorf( + "skipping modifying Gopkg.toml - file already exists and is unmanaged") + } + return strings.Join(userLines, "\n"), nil +} + +// DefaultGopkgHeader is the default header used to separate user managed lines and controller-manager managed lines +const DefaultGopkgHeader = "# STANZAS BELOW ARE GENERATED AND MAY BE WRITTEN - DO NOT MODIFY BELOW THIS LINE." + +// DefaultGopkgUserContent is the default user managed lines to provide. +const DefaultGopkgUserContent = `required = [ + "github.com/emicklei/go-restful", + "github.com/onsi/ginkgo", # for test framework + "github.com/onsi/gomega", # for test matchers + "k8s.io/client-go/plugin/pkg/client/auth/gcp", # for development against gcp + "k8s.io/code-generator/cmd/client-gen", # for go generate + "k8s.io/code-generator/cmd/deepcopy-gen", # for go generate + "sigs.k8s.io/controller-tools/cmd/controller-gen", # for crd/rbac generation + "sigs.k8s.io/controller-runtime/pkg/client/config", + "sigs.k8s.io/controller-runtime/pkg/controller", + "sigs.k8s.io/controller-runtime/pkg/handler", + "sigs.k8s.io/controller-runtime/pkg/manager", + "sigs.k8s.io/controller-runtime/pkg/runtime/signals", + "sigs.k8s.io/controller-runtime/pkg/source", + "sigs.k8s.io/testing_frameworks/integration", # for integration testing + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1", + ] + +[prune] + go-tests = true + +` + +var depTemplate = `{{ .UserContent }} +# STANZAS BELOW ARE GENERATED AND MAY BE WRITTEN - DO NOT MODIFY BELOW THIS LINE. + +{{ range $element := .Stanzas -}} +[[{{ .Type }}]] +name="{{ .Name }}" +{{ if .Version }}version="{{.Version}}"{{ end }} +{{ if .Revision }}revision="{{.Revision}}"{{ end }} +{{ end -}} + +[[constraint]] + name="sigs.k8s.io/controller-runtime" + version="v0.1.1" + +[[constraint]] + name="sigs.k8s.io/controller-tools" + version="v0.1.1" + +# For dependency below: Refer to issue https://github.com/golang/dep/issues/1799 +[[override]] +name = "gopkg.in/fsnotify.v1" +source = "https://github.com/fsnotify/fsnotify.git" +version="v1.4.7" +` diff --git a/pkg/scaffold/project/kustomize.go b/pkg/scaffold/project/kustomize.go new file mode 100644 index 00000000000..dc6590b000e --- /dev/null +++ b/pkg/scaffold/project/kustomize.go @@ -0,0 +1,87 @@ +/* +Copyright 2018 The Kubernetes 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 project + +import ( + "os" + "path/filepath" + + "sigs.k8s.io/kubebuilder/pkg/scaffold/input" +) + +var _ input.File = &Kustomize{} + +// Kustomize scaffolds the Kustomization file. +type Kustomize struct { + input.Input + + // Prefix to use for name prefix customization + Prefix string +} + +// GetInput implements input.File +func (c *Kustomize) GetInput() (input.Input, error) { + if c.Path == "" { + c.Path = filepath.Join("config", "default", "kustomization.yaml") + } + if c.Prefix == "" { + // use directory name as prefix + dir, err := os.Getwd() + if err != nil { + return input.Input{}, err + } + c.Prefix = filepath.Base(dir) + } + c.TemplateBody = kustomizeTemplate + c.Input.IfExistsAction = input.Error + return c.Input, nil +} + +var kustomizeTemplate = `# Adds namespace to all resources. +namespace: {{.Prefix}}-system + +# Value of this field is prepended to the +# names of all resources, e.g. a deployment named +# "wordpress" becomes "alices-wordpress". +# Note that it should also match with the prefix (text before '-') of the namespace +# field above. +namePrefix: {{.Prefix}}- + +# Labels to add to all resources and selectors. +#commonLabels: +# someName: someValue + +# Each entry in this list must resolve to an existing +# resource definition in YAML. These are the resource +# files that kustomize reads, modifies and emits as a +# YAML string, with resources separated by document +# markers ("---"). +resources: +- ../rbac/rbac_role.yaml +- ../rbac/rbac_role_binding.yaml +- ../manager/manager.yaml + +patches: +- manager_image_patch.yaml + +vars: +- name: WEBHOOK_SECRET_NAME + objref: + kind: Secret + name: webhook-server-secret + apiVersion: v1 +` diff --git a/pkg/scaffold/project/kustomize_image_patch.go b/pkg/scaffold/project/kustomize_image_patch.go new file mode 100644 index 00000000000..5cca9698d9d --- /dev/null +++ b/pkg/scaffold/project/kustomize_image_patch.go @@ -0,0 +1,61 @@ +/* +Copyright 2018 The Kubernetes 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 project + +import ( + "path/filepath" + + "sigs.k8s.io/kubebuilder/pkg/scaffold/input" +) + +var _ input.File = &KustomizeImagePatch{} + +// KustomizeImagePatch scaffolds the patch file for customizing image URL +// manifest file for manager resource. +type KustomizeImagePatch struct { + input.Input + + // ImageURL to use for controller image in manager's manifest. + ImageURL string +} + +// GetInput implements input.File +func (c *KustomizeImagePatch) GetInput() (input.Input, error) { + if c.Path == "" { + c.Path = filepath.Join("config", "default", "manager_image_patch.yaml") + } + if c.ImageURL == "" { + c.ImageURL = "IMAGE_URL" + } + c.TemplateBody = kustomizeImagePatchTemplate + c.Input.IfExistsAction = input.Error + return c.Input, nil +} + +var kustomizeImagePatchTemplate = `apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: controller-manager + namespace: system +spec: + template: + spec: + containers: + # Change the value of image field below to your controller image URL + - image: {{ .ImageURL }} + name: manager +` diff --git a/pkg/scaffold/project/makefile.go b/pkg/scaffold/project/makefile.go new file mode 100644 index 00000000000..72ef193b0b7 --- /dev/null +++ b/pkg/scaffold/project/makefile.go @@ -0,0 +1,103 @@ +/* +Copyright 2018 The Kubernetes 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 project + +import ( + "sigs.k8s.io/kubebuilder/pkg/scaffold/input" +) + +var _ input.File = &Makefile{} + +// Makefile scaffolds the Makefile +type Makefile struct { + input.Input + // Image is controller manager image name + Image string + + // path for controller-tools pkg + ControllerToolsPath string +} + +// GetInput implements input.File +func (c *Makefile) GetInput() (input.Input, error) { + if c.Path == "" { + c.Path = "Makefile" + } + if c.Image == "" { + c.Image = "controller:latest" + } + if c.ControllerToolsPath == "" { + c.ControllerToolsPath = "vendor/sigs.k8s.io/controller-tools" + } + c.TemplateBody = makefileTemplate + c.Input.IfExistsAction = input.Error + return c.Input, nil +} + +var makefileTemplate = ` +# Image URL to use all building/pushing image targets +IMG ?= {{ .Image }} + +all: test manager + +# Run tests +test: generate fmt vet manifests + go test ./pkg/... ./cmd/... -coverprofile cover.out + +# Build manager binary +manager: generate fmt vet + go build -o bin/manager {{ .Repo }}/cmd/manager + +# Run against the configured Kubernetes cluster in ~/.kube/config +run: generate fmt vet + go run ./cmd/manager/main.go + +# Install CRDs into a cluster +install: manifests + kubectl apply -f config/crds + +# Deploy controller in the configured Kubernetes cluster in ~/.kube/config +deploy: manifests + kubectl apply -f config/crds + kustomize build config/default | kubectl apply -f - + +# Generate manifests e.g. CRD, RBAC etc. +manifests: + go run {{ .ControllerToolsPath }}/cmd/controller-gen/main.go all + +# Run go fmt against code +fmt: + go fmt ./pkg/... ./cmd/... + +# Run go vet against code +vet: + go vet ./pkg/... ./cmd/... + +# Generate code +generate: + go generate ./pkg/... ./cmd/... + +# Build the docker image +docker-build: test + docker build . -t ${IMG} + @echo "updating kustomize image patch file for manager resource" + sed -i'' -e 's@image: .*@image: '"${IMG}"'@' ./config/default/manager_image_patch.yaml + +# Push the docker image +docker-push: + docker push ${IMG} +` diff --git a/pkg/scaffold/project/project.go b/pkg/scaffold/project/project.go new file mode 100644 index 00000000000..f474640dc88 --- /dev/null +++ b/pkg/scaffold/project/project.go @@ -0,0 +1,95 @@ +/* +Copyright 2018 The Kubernetes 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 project + +import ( + "fmt" + "go/build" + "os" + "path/filepath" + "strings" + + yaml "gopkg.in/yaml.v2" + "sigs.k8s.io/kubebuilder/pkg/scaffold/input" +) + +var _ input.File = &Project{} + +// Project scaffolds the PROJECT file with project metadata +type Project struct { + // Path is the output file location - defaults to PROJECT + Path string + + input.ProjectFile +} + +// GetInput implements input.File +func (c *Project) GetInput() (input.Input, error) { + if c.Path == "" { + c.Path = "PROJECT" + } + if c.Repo == "" { + r, err := c.repoFromGopathAndWd(os.Getenv("GOPATH"), os.Getwd) + if err != nil { + return input.Input{}, err + } + c.Repo = r + } + + out, err := yaml.Marshal(c.ProjectFile) + if err != nil { + return input.Input{}, err + } + + return input.Input{ + Path: c.Path, + TemplateBody: string(out), + Repo: c.Repo, + Version: c.Version, + Domain: c.Domain, + IfExistsAction: input.Error, + }, nil +} + +func (Project) repoFromGopathAndWd(gopath string, getwd func() (string, error)) (string, error) { + // Assume the working dir is the root of the repo + wd, err := getwd() + if err != nil { + return "", err + } + + // Strip the GOPATH from the working dir to get the go package of the repo + if len(gopath) == 0 { + gopath = build.Default.GOPATH + } + goSrc := filepath.Join(gopath, "src") + + // Make sure the GOPATH is set and the working dir is under the GOPATH + if !strings.HasPrefix(filepath.Dir(wd), goSrc) { + return "", fmt.Errorf("working directory must be a project directory under "+ + "$GOPATH/src/\n- GOPATH=%s\n- WD=%s", gopath, wd) + } + + // Figure out the repo name by removing $GOPATH/src from the working directory - e.g. + // '$GOPATH/src/kubernetes-sigs/controller-tools' becomes 'kubernetes-sigs/controller-tools' + repo := "" + for wd != goSrc { + repo = filepath.Join(filepath.Base(wd), repo) + wd = filepath.Dir(wd) + } + return repo, nil +} diff --git a/pkg/scaffold/project/project_suite_test.go b/pkg/scaffold/project/project_suite_test.go new file mode 100644 index 00000000000..9c31ffadd3f --- /dev/null +++ b/pkg/scaffold/project/project_suite_test.go @@ -0,0 +1,13 @@ +package project_test + +import ( + "testing" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +func TestProject(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Project Suite") +} diff --git a/pkg/scaffold/project/project_test.go b/pkg/scaffold/project/project_test.go new file mode 100644 index 00000000000..a29950e14ea --- /dev/null +++ b/pkg/scaffold/project/project_test.go @@ -0,0 +1,311 @@ +package project + +import ( + "fmt" + "io/ioutil" + "os" + "path/filepath" + "strings" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + "sigs.k8s.io/kubebuilder/pkg/scaffold" + "sigs.k8s.io/kubebuilder/pkg/scaffold/input" + "sigs.k8s.io/kubebuilder/pkg/scaffold/scaffoldtest" +) + +var _ = Describe("Project", func() { + var result *scaffoldtest.TestResult + var writeToPath, goldenPath string + var s *scaffold.Scaffold + + JustBeforeEach(func() { + s, result = scaffoldtest.NewTestScaffold(writeToPath, goldenPath) + s.BoilerplateOptional = true + s.ProjectOptional = true + }) + + Describe("scaffolding a boilerplate file", func() { + BeforeEach(func() { + goldenPath = filepath.Join("hack", "boilerplate.go.txt") + writeToPath = goldenPath + }) + + It("should match the golden file", func() { + instance := &Boilerplate{Year: "2018", License: "apache2", Owner: "The Kubernetes authors"} + Expect(s.Execute(input.Options{}, instance)).NotTo(HaveOccurred()) + Expect(result.Actual.String()).To(BeEquivalentTo(result.Golden)) + }) + + It("should skip writing boilerplate if the file exists", func() { + i, err := (&Boilerplate{}).GetInput() + Expect(err).NotTo(HaveOccurred()) + Expect(i.IfExistsAction).To(Equal(input.Skip)) + }) + + Context("for apache2", func() { + It("should write the apache2 boilerplate with specified owners", func() { + instance := &Boilerplate{Year: "2018", Owner: "Example Owners"} + Expect(s.Execute(input.Options{}, instance)).NotTo(HaveOccurred()) + e := strings.Replace( + result.Golden, "The Kubernetes authors", "Example Owners", -1) + Expect(result.Actual.String()).To(BeEquivalentTo(e)) + }) + + It("should use apache2 as the default", func() { + instance := &Boilerplate{Year: "2018", Owner: "The Kubernetes authors"} + Expect(s.Execute(input.Options{}, instance)).NotTo(HaveOccurred()) + Expect(result.Actual.String()).To(BeEquivalentTo(result.Golden)) + }) + }) + + Context("for none", func() { + It("should write the empty boilerplate", func() { + // Scaffold a boilerplate file + instance := &Boilerplate{Year: "2019", License: "none", Owner: "Example Owners"} + Expect(s.Execute(input.Options{}, instance)).NotTo(HaveOccurred()) + Expect(result.Actual.String()).To(BeEquivalentTo(`/* +Copyright 2019 Example Owners. +*/`)) + }) + }) + + Context("if the boilerplate is given", func() { + It("should skip writing Gopkg.toml", func() { + instance := &Boilerplate{} + instance.Boilerplate = `/* Hello World */` + + Expect(s.Execute(input.Options{}, instance)).NotTo(HaveOccurred()) + Expect(result.Actual.String()).To(BeEquivalentTo(`/* Hello World */`)) + }) + }) + }) + + Describe("scaffolding a Gopkg.toml", func() { + BeforeEach(func() { + goldenPath = filepath.Join("Gopkg.toml") + writeToPath = goldenPath + }) + Context("with defaults ", func() { + It("should match the golden file", func() { + instance := &GopkgToml{} + Expect(s.Execute(input.Options{}, instance)).NotTo(HaveOccurred()) + + // Verify the contents matches the golden file. + Expect(result.Actual.String()).To(BeEquivalentTo(result.Golden)) + }) + }) + + Context("if the file exists without the header", func() { + var f *os.File + var err error + BeforeEach(func() { + f, err = ioutil.TempFile("", "controller-tools-pkg-scaffold-project") + Expect(err).NotTo(HaveOccurred()) + writeToPath = f.Name() + }) + + It("should skip writing Gopkg.toml", func() { + e := strings.Replace(string(result.Golden), DefaultGopkgHeader, "", -1) + _, err = f.Write([]byte(e)) + Expect(err).NotTo(HaveOccurred()) + Expect(f.Close()).NotTo(HaveOccurred()) + + instance := &GopkgToml{} + instance.Input.Path = f.Name() + + err = s.Execute(input.Options{}, instance) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring( + "skipping modifying Gopkg.toml - file already exists and is unmanaged")) + }) + }) + + Context("if the file exists with existing user content", func() { + var f *os.File + var err error + BeforeEach(func() { + f, err = ioutil.TempFile("", "controller-tools-pkg-scaffold-project") + Expect(err).NotTo(HaveOccurred()) + writeToPath = f.Name() + }) + + It("should keep the user content", func() { + e := strings.Replace(string(result.Golden), + DefaultGopkgUserContent, "Hello World", -1) + _, err = f.Write([]byte(e)) + Expect(err).NotTo(HaveOccurred()) + Expect(f.Close()).NotTo(HaveOccurred()) + + fmt.Printf("Write\n\n") + instance := &GopkgToml{} + instance.Input.Path = f.Name() + + Expect(s.Execute(input.Options{}, instance)).NotTo(HaveOccurred()) + Expect(result.Actual.String()).To(BeEquivalentTo(e)) + }) + }) + + Context("if no file exists", func() { + var f *os.File + var err error + BeforeEach(func() { + f, err = ioutil.TempFile("", "controller-tools-pkg-scaffold-project") + Expect(err).NotTo(HaveOccurred()) + Expect(os.Remove(f.Name())).NotTo(HaveOccurred()) + writeToPath = f.Name() + }) + + It("should use the default user content", func() { + instance := &GopkgToml{} + instance.Input.Path = writeToPath + + err = s.Execute(input.Options{}, instance) + // Verify the contents matches the golden file. + Expect(result.Actual.String()).To(BeEquivalentTo(result.Golden)) + }) + }) + }) + + Describe("scaffolding a Makefile", func() { + BeforeEach(func() { + goldenPath = filepath.Join("Makefile") + writeToPath = goldenPath + }) + Context("with defaults ", func() { + It("should match the golden file", func() { + instance := &Makefile{Image: "controller:latest", ControllerToolsPath: ".."} + instance.Repo = "sigs.k8s.io/controller-tools/test" + Expect(s.Execute(input.Options{}, instance)).NotTo(HaveOccurred()) + + // Verify the contents matches the golden file. + Expect(result.Actual.String()).To(BeEquivalentTo(result.Golden)) + }) + }) + }) + + Describe("scaffolding a Kustomization", func() { + BeforeEach(func() { + goldenPath = filepath.Join("config", "default", "kustomization.yaml") + writeToPath = goldenPath + }) + Context("with defaults ", func() { + It("should match the golden file", func() { + instance := &Kustomize{Prefix: "test"} + instance.Repo = "sigs.k8s.io/controller-tools/test" + Expect(s.Execute(input.Options{}, instance)).NotTo(HaveOccurred()) + + // Verify the contents matches the golden file. + Expect(result.Actual.String()).To(BeEquivalentTo(result.Golden)) + }) + }) + }) + + Describe("scaffolding a Kustomize image patch", func() { + BeforeEach(func() { + goldenPath = filepath.Join("config", "default", "manager_image_patch.yaml") + writeToPath = goldenPath + }) + Context("with defaults ", func() { + It("should match the golden file", func() { + instance := &KustomizeImagePatch{} + instance.Repo = "sigs.k8s.io/controller-tools/test" + Expect(s.Execute(input.Options{}, instance)).NotTo(HaveOccurred()) + + // Verify the contents matches the golden file. + Expect(result.Actual.String()).To(BeEquivalentTo(result.Golden)) + }) + }) + }) + + Describe("scaffolding a .gitignore", func() { + BeforeEach(func() { + goldenPath = filepath.Join(".gitignore") + writeToPath = goldenPath + }) + Context("with defaults ", func() { + It("should match the golden file", func() { + instance := &GitIgnore{} + Expect(s.Execute(input.Options{}, instance)).NotTo(HaveOccurred()) + + // Verify the contents matches the golden file. + Expect(result.Actual.String()).To(BeEquivalentTo(result.Golden)) + }) + }) + }) + + Describe("scaffolding a PROEJCT", func() { + BeforeEach(func() { + goldenPath = filepath.Join("PROJECT") + writeToPath = goldenPath + }) + Context("with defaults", func() { + It("should match the golden file", func() { + instance := &Project{} + instance.Version = "2" + instance.Domain = "testproject.org" + instance.Repo = "sigs.k8s.io/controller-tools/test" + Expect(s.Execute(input.Options{}, instance)).NotTo(HaveOccurred()) + + // Verify the contents matches the golden file. + Expect(result.Actual.String()).To(BeEquivalentTo(result.Golden)) + }) + }) + + Context("by calling repoFromGopathAndWd", func() { + It("should return the directory if it is under the gopath", func() { + instance := &Project{} + repo, err := instance.repoFromGopathAndWd("/home/fake/go", func() (string, error) { + return "/home/fake/go/src/kubernetes-sigs/controller-tools", nil + }) + Expect(err).NotTo(HaveOccurred()) + Expect(repo).To(Equal("kubernetes-sigs/controller-tools")) + }) + + It("should return an error if the wd is not under GOPATH", func() { + instance := &Project{} + _, err := instance.repoFromGopathAndWd("/home/fake/go/src", func() (string, error) { + return "/home/fake", nil + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("")) + }) + + It("should return an error if the wd is not under GOPATH", func() { + instance := &Project{} + _, err := instance.repoFromGopathAndWd("/home/fake/go/src", func() (string, error) { + return "/home/fake/go", nil + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("working directory must be a project directory")) + }) + + It("should return an error if it cannot get the WD", func() { + instance := &Project{} + e := fmt.Errorf("expected error") + _, err := instance.repoFromGopathAndWd("/home/fake/go/src", func() (string, error) { + return "", e + }) + Expect(err).To(HaveOccurred()) + Expect(err).To(Equal(e)) + }) + + It("should use the build.Default GOPATH if none is defined", func() { + instance := &Project{} + instance.repoFromGopathAndWd("", func() (string, error) { + return "/home/fake/go/src/project", nil + }) + }) + }) + + Context("by calling GetInput", func() { + It("should return the Repo from GetInput", func() { + instance := &Project{} + i, err := instance.GetInput() + Expect(err).NotTo(HaveOccurred()) + Expect(i.Path).To(Equal("PROJECT")) + Expect(i.Repo).To(Equal("sigs.k8s.io/kubebuilder/pkg/scaffold/project")) + }) + }) + }) +}) diff --git a/pkg/scaffold/project/projectutil/projectutil.go b/pkg/scaffold/project/projectutil/projectutil.go new file mode 100644 index 00000000000..921377d7b2a --- /dev/null +++ b/pkg/scaffold/project/projectutil/projectutil.go @@ -0,0 +1,48 @@ +/* +Copyright 2018 The Kubernetes 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 projectutil + +import ( + "fmt" + "io/ioutil" + "os" + "path/filepath" + "strings" +) + +// GetProjectDir walks up the tree until it finds a directory with a PROJECT file +func GetProjectDir() (string, error) { + gopath := os.Getenv("GOPATH") + dir, err := filepath.Abs(".") + if err != nil { + return "", err + } + // Walk up until we find PROJECT or are outside the GOPATH + for strings.Contains(dir, gopath) { + files, err := ioutil.ReadDir(dir) + if err != nil { + return "", err + } + for _, f := range files { + if f.Name() == "PROJECT" { + return dir, nil + } + } + dir = filepath.Dir(dir) + } + return "", fmt.Errorf("unable to locate PROJECT file") +} diff --git a/pkg/scaffold/resource/addtoscheme.go b/pkg/scaffold/resource/addtoscheme.go new file mode 100644 index 00000000000..440d515b5c9 --- /dev/null +++ b/pkg/scaffold/resource/addtoscheme.go @@ -0,0 +1,58 @@ +/* +Copyright 2018 The Kubernetes 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 resource + +import ( + "fmt" + "path/filepath" + + "sigs.k8s.io/kubebuilder/pkg/scaffold/input" +) + +var _ input.File = &AddToScheme{} + +// AddToScheme scaffolds the code to add the resource to a SchemeBuilder. +type AddToScheme struct { + input.Input + + // Resource is a resource in the API group + Resource *Resource +} + +// GetInput implements input.File +func (a *AddToScheme) GetInput() (input.Input, error) { + if a.Path == "" { + a.Path = filepath.Join("pkg", "apis", fmt.Sprintf( + "addtoscheme_%s_%s.go", a.Resource.Group, a.Resource.Version)) + } + a.TemplateBody = addResourceTemplate + return a.Input, nil +} + +var addResourceTemplate = `{{ .Boilerplate }} + +package apis + +import ( + "{{ .Repo }}/pkg/apis/{{ .Resource.Group }}/{{ .Resource.Version }}" +) + +func init() { + // Register the types with the Scheme so the components can map objects to GroupVersionKinds and back + AddToSchemes = append(AddToSchemes, {{ .Resource.Version }}.SchemeBuilder.AddToScheme) +} +` diff --git a/pkg/scaffold/resource/crd.go b/pkg/scaffold/resource/crd.go new file mode 100644 index 00000000000..d99b7ae5e14 --- /dev/null +++ b/pkg/scaffold/resource/crd.go @@ -0,0 +1,77 @@ +/* +Copyright 2018 The Kubernetes 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 resource + +import ( + "fmt" + "path/filepath" + + "strings" + + "github.com/markbates/inflect" + "sigs.k8s.io/kubebuilder/pkg/scaffold/input" +) + +var _ input.File = &CRD{} + +// CRD scaffolds a CRD yaml file. +type CRD struct { + input.Input + + // Scope is Namespaced or Cluster + Scope string + + // Plural is the plural lowercase of kind + Plural string + + // Resource is a resource in the API group + Resource *Resource +} + +// GetInput implements input.File +func (c *CRD) GetInput() (input.Input, error) { + if c.Path == "" { + c.Path = filepath.Join("config", "crds", fmt.Sprintf( + "%s_%s_%s.yaml", c.Resource.Group, c.Resource.Version, strings.ToLower(c.Resource.Kind))) + } + c.Scope = "Namespaced" + if !c.Resource.Namespaced { + c.Scope = "Cluster" + } + if c.Plural == "" { + c.Plural = strings.ToLower(inflect.Pluralize(c.Resource.Kind)) + } + + c.IfExistsAction = input.Error + c.TemplateBody = crdTemplate + return c.Input, nil +} + +var crdTemplate = `apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + labels: + controller-tools.k8s.io: "1.0" + name: {{ .Plural }}.{{ .Resource.Group }}.{{ .Domain }} +spec: + group: {{ .Resource.Group }}.{{ .Domain }} + version: "{{ .Resource.Version }}" + names: + kind: {{ .Resource.Kind }} + plural: {{ .Plural }} + scope: {{ .Scope }} +` diff --git a/pkg/scaffold/resource/crd_sample.go b/pkg/scaffold/resource/crd_sample.go new file mode 100644 index 00000000000..ef734aa6516 --- /dev/null +++ b/pkg/scaffold/resource/crd_sample.go @@ -0,0 +1,58 @@ +/* +Copyright 2018 The Kubernetes 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 resource + +import ( + "fmt" + "path/filepath" + "strings" + + "sigs.k8s.io/kubebuilder/pkg/scaffold/input" +) + +var _ input.File = &CRD{} + +// CRDSample scaffolds a manifest for CRD sample. +type CRDSample struct { + input.Input + + // Resource is a resource in the API group + Resource *Resource +} + +// GetInput implements input.File +func (c *CRDSample) GetInput() (input.Input, error) { + if c.Path == "" { + c.Path = filepath.Join("config", "samples", fmt.Sprintf( + "%s_%s_%s.yaml", c.Resource.Group, c.Resource.Version, strings.ToLower(c.Resource.Kind))) + } + + c.IfExistsAction = input.Error + c.TemplateBody = crdSampleTemplate + return c.Input, nil +} + +var crdSampleTemplate = `apiVersion: {{ .Resource.Group }}.{{ .Domain }}/{{ .Resource.Version }} +kind: {{ .Resource.Kind }} +metadata: + labels: + controller-tools.k8s.io: "1.0" + name: {{ lower .Resource.Kind }}-sample +spec: + # Add fields here + foo: bar +` diff --git a/pkg/scaffold/resource/doc.go b/pkg/scaffold/resource/doc.go new file mode 100644 index 00000000000..41d02517959 --- /dev/null +++ b/pkg/scaffold/resource/doc.go @@ -0,0 +1,56 @@ +/* +Copyright 2018 The Kubernetes 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 resource + +import ( + "path/filepath" + + "sigs.k8s.io/kubebuilder/pkg/scaffold/input" +) + +var _ input.File = &Doc{} + +// Doc scaffolds the pkg/apis/group/version/doc.go directory +type Doc struct { + input.Input + + // Resource is a resource for the API version + Resource *Resource + + // Comments are additional lines to write to the doc.go file + Comments []string +} + +// GetInput implements input.File +func (a *Doc) GetInput() (input.Input, error) { + if a.Path == "" { + a.Path = filepath.Join("pkg", "apis", a.Resource.Group, a.Resource.Version, "doc.go") + } + a.TemplateBody = docGoTemplate + return a.Input, nil +} + +var docGoTemplate = `{{ .Boilerplate }} + +// Package {{.Resource.Version}} contains API Schema definitions for the {{ .Resource.Group }} {{.Resource.Version}} API group +// +k8s:openapi-gen=true +// +k8s:deepcopy-gen=package,register +// +k8s:conversion-gen={{ .Repo }}/pkg/apis/{{ .Resource.Group }} +// +k8s:defaulter-gen=TypeMeta +// +groupName={{ .Resource.Group }}.{{ .Domain }} +package {{.Resource.Version}} +` diff --git a/pkg/scaffold/resource/group.go b/pkg/scaffold/resource/group.go new file mode 100644 index 00000000000..f156ff6edd8 --- /dev/null +++ b/pkg/scaffold/resource/group.go @@ -0,0 +1,48 @@ +/* +Copyright 2018 The Kubernetes 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 resource + +import ( + "path/filepath" + + "sigs.k8s.io/kubebuilder/pkg/scaffold/input" +) + +var _ input.File = &Group{} + +// Group scaffolds the pkg/apis/group/group.go +type Group struct { + input.Input + + // Resource is a resource in the API group + Resource *Resource +} + +// GetInput implements input.File +func (g *Group) GetInput() (input.Input, error) { + if g.Path == "" { + g.Path = filepath.Join("pkg", "apis", g.Resource.Group, "group.go") + } + g.TemplateBody = groupTemplate + return g.Input, nil +} + +var groupTemplate = `{{ .Boilerplate }} + +// Package {{ .Resource.Group }} contains {{ .Resource.Group }} API versions +package {{ .Resource.Group }} +` diff --git a/pkg/scaffold/resource/register.go b/pkg/scaffold/resource/register.go new file mode 100644 index 00000000000..13c91af61d2 --- /dev/null +++ b/pkg/scaffold/resource/register.go @@ -0,0 +1,76 @@ +/* +Copyright 2018 The Kubernetes 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 resource + +import ( + "path/filepath" + + "sigs.k8s.io/kubebuilder/pkg/scaffold/input" +) + +var _ input.File = &Register{} + +// Register scaffolds the pkg/apis/group/version/register.go file +type Register struct { + input.Input + + // Resource is the resource to scaffold the types_test.go file for + Resource *Resource +} + +// GetInput implements input.File +func (r *Register) GetInput() (input.Input, error) { + if r.Path == "" { + r.Path = filepath.Join("pkg", "apis", r.Resource.Group, r.Resource.Version, "register.go") + } + r.TemplateBody = registerTemplate + return r.Input, nil +} + +var registerTemplate = `{{ .Boilerplate }} + +// NOTE: Boilerplate only. Ignore this file. + +// Package {{.Resource.Version}} contains API Schema definitions for the {{ .Resource.Group }} {{.Resource.Version}} API group +// +k8s:openapi-gen=true +// +k8s:deepcopy-gen=package,register +// +k8s:conversion-gen={{ .Repo }}/pkg/apis/{{ .Resource.Group }} +// +k8s:defaulter-gen=TypeMeta +// +groupName={{ .Resource.Group }}.{{ .Domain }} +package {{.Resource.Version}} + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/runtime/scheme" +) + +var ( + // SchemeGroupVersion is group version used to register these objects + SchemeGroupVersion = schema.GroupVersion{Group: "{{ .Resource.Group }}.{{ .Domain }}", Version: "{{ .Resource.Version }}"} + + // SchemeBuilder is used to add go types to the GroupVersionKind scheme + SchemeBuilder = &scheme.Builder{GroupVersion: SchemeGroupVersion} + + // AddToScheme is required by pkg/client/... + AddToScheme = SchemeBuilder.AddToScheme +) + +// Resource is required by pkg/client/listers/... +func Resource(resource string) schema.GroupResource { + return SchemeGroupVersion.WithResource(resource).GroupResource() +} +` diff --git a/pkg/scaffold/resource/resource.go b/pkg/scaffold/resource/resource.go new file mode 100644 index 00000000000..6d1b7deda1d --- /dev/null +++ b/pkg/scaffold/resource/resource.go @@ -0,0 +1,84 @@ +/* +Copyright 2018 The Kubernetes 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 resource + +import ( + "fmt" + "regexp" + "strings" + + "github.com/markbates/inflect" +) + +// Resource contains the information required to scaffold files for a resource. +type Resource struct { + // Namespaced is true if the resource is namespaced + Namespaced bool + + // Group is the API Group. Does not contain the domain. + Group string + + // Version is the API version - e.g. v1beta1 + Version string + + // Kind is the API Kind. + Kind string + + // Resource is the API Resource. + Resource string + + // ShortNames is the list of resource shortnames. + ShortNames []string + + // CreateExampleReconcileBody will create a Deployment in the Reconcile example + CreateExampleReconcileBody bool +} + +// Validate checks the Resource values to make sure they are valid. +func (r *Resource) Validate() error { + if len(r.Group) == 0 { + return fmt.Errorf("group cannot be empty") + } + if len(r.Version) == 0 { + return fmt.Errorf("version cannot be empty") + } + if len(r.Kind) == 0 { + return fmt.Errorf("kind cannot be empty") + } + + rs := inflect.NewDefaultRuleset() + if len(r.Resource) == 0 { + r.Resource = rs.Pluralize(strings.ToLower(r.Kind)) + } + + groupMatch := regexp.MustCompile("^[a-z]+$") + if !groupMatch.MatchString(r.Group) { + return fmt.Errorf("group must match ^[a-z]+$ (was %s)", r.Group) + } + + versionMatch := regexp.MustCompile("^v\\d+(alpha\\d+|beta\\d+)?$") + if !versionMatch.MatchString(r.Version) { + return fmt.Errorf( + "version must match ^v\\d+(alpha\\d+|beta\\d+)?$ (was %s)", r.Version) + } + + if r.Kind != inflect.Camelize(r.Kind) { + return fmt.Errorf("Kind must be camelcase (expected %s was %s)", inflect.Camelize(r.Kind), r.Kind) + } + + return nil +} diff --git a/pkg/scaffold/resource/resource_suite_test.go b/pkg/scaffold/resource/resource_suite_test.go new file mode 100644 index 00000000000..0e6d0ffeded --- /dev/null +++ b/pkg/scaffold/resource/resource_suite_test.go @@ -0,0 +1,13 @@ +package resource_test + +import ( + "testing" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +func TestResource(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Resource Suite") +} diff --git a/pkg/scaffold/resource/resource_test.go b/pkg/scaffold/resource/resource_test.go new file mode 100644 index 00000000000..864cc0a3e1d --- /dev/null +++ b/pkg/scaffold/resource/resource_test.go @@ -0,0 +1,184 @@ +package resource + +import ( + "path/filepath" + + "fmt" + + "strings" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + "sigs.k8s.io/kubebuilder/pkg/scaffold/input" + "sigs.k8s.io/kubebuilder/pkg/scaffold/scaffoldtest" +) + +var _ = Describe("Resource", func() { + Describe("scaffolding an API", func() { + It("should succeed if the Resource is valid", func() { + instance := &Resource{Group: "crew", Version: "v1", Kind: "FirstMate"} + Expect(instance.Validate()).To(Succeed()) + }) + + It("should fail if the Group is not specified", func() { + instance := &Resource{Version: "v1", Kind: "FirstMate"} + Expect(instance.Validate()).NotTo(Succeed()) + Expect(instance.Validate().Error()).To(ContainSubstring("group cannot be empty")) + }) + + It("should fail if the Group is not all lowercase", func() { + instance := &Resource{Group: "Crew", Version: "v1", Kind: "FirstMate"} + Expect(instance.Validate()).NotTo(Succeed()) + Expect(instance.Validate().Error()).To(ContainSubstring("group must match ^[a-z]+$ (was Crew)")) + }) + + It("should fail if the Group contains non-alpha characters", func() { + instance := &Resource{Group: "crew1", Version: "v1", Kind: "FirstMate"} + Expect(instance.Validate()).NotTo(Succeed()) + Expect(instance.Validate().Error()).To(ContainSubstring("group must match ^[a-z]+$ (was crew1)")) + }) + + It("should fail if the Version is not specified", func() { + instance := &Resource{Group: "crew", Kind: "FirstMate"} + Expect(instance.Validate()).NotTo(Succeed()) + Expect(instance.Validate().Error()).To(ContainSubstring("version cannot be empty")) + }) + + It("should fail if the Version does not match the version format", func() { + instance := &Resource{Group: "crew", Version: "1", Kind: "FirstMate"} + Expect(instance.Validate()).NotTo(Succeed()) + Expect(instance.Validate().Error()).To(ContainSubstring( + `version must match ^v\d+(alpha\d+|beta\d+)?$ (was 1)`)) + + instance = &Resource{Group: "crew", Version: "1beta1", Kind: "FirstMate"} + Expect(instance.Validate()).NotTo(Succeed()) + Expect(instance.Validate().Error()).To(ContainSubstring( + `version must match ^v\d+(alpha\d+|beta\d+)?$ (was 1beta1)`)) + + instance = &Resource{Group: "crew", Version: "a1beta1", Kind: "FirstMate"} + Expect(instance.Validate()).NotTo(Succeed()) + Expect(instance.Validate().Error()).To(ContainSubstring( + `version must match ^v\d+(alpha\d+|beta\d+)?$ (was a1beta1)`)) + + instance = &Resource{Group: "crew", Version: "v1beta", Kind: "FirstMate"} + Expect(instance.Validate()).NotTo(Succeed()) + Expect(instance.Validate().Error()).To(ContainSubstring( + `version must match ^v\d+(alpha\d+|beta\d+)?$ (was v1beta)`)) + + instance = &Resource{Group: "crew", Version: "v1beta1alpha1", Kind: "FirstMate"} + Expect(instance.Validate()).NotTo(Succeed()) + Expect(instance.Validate().Error()).To(ContainSubstring( + `version must match ^v\d+(alpha\d+|beta\d+)?$ (was v1beta1alpha1)`)) + }) + + It("should fail if the Kind is not specified", func() { + instance := &Resource{Group: "crew", Version: "v1"} + Expect(instance.Validate()).NotTo(Succeed()) + Expect(instance.Validate().Error()).To(ContainSubstring("kind cannot be empty")) + }) + + It("should fail if the Kind is not camel cased", func() { + // Base case + instance := &Resource{Group: "crew", Kind: "FirstMate", Version: "v1"} + Expect(instance.Validate()).To(Succeed()) + + // Can't detect this case :( + instance = &Resource{Group: "crew", Kind: "Firstmate", Version: "v1"} + Expect(instance.Validate()).To(Succeed()) + + instance = &Resource{Group: "crew", Kind: "firstMate", Version: "v1"} + Expect(instance.Validate()).NotTo(Succeed()) + Expect(instance.Validate().Error()).To(ContainSubstring( + `Kind must be camelcase (expected FirstMate was firstMate)`)) + + instance = &Resource{Group: "crew", Kind: "firstmate", Version: "v1"} + Expect(instance.Validate()).NotTo(Succeed()) + Expect(instance.Validate().Error()).To(ContainSubstring( + `Kind must be camelcase (expected Firstmate was firstmate)`)) + }) + + It("should default the Resource by pluralizing the Kind", func() { + instance := &Resource{Group: "crew", Kind: "FirstMate", Version: "v1"} + Expect(instance.Validate()).To(Succeed()) + Expect(instance.Resource).To(Equal("firstmates")) + + instance = &Resource{Group: "crew", Kind: "Fish", Version: "v1"} + Expect(instance.Validate()).To(Succeed()) + Expect(instance.Resource).To(Equal("fish")) + + instance = &Resource{Group: "crew", Kind: "Helmswoman", Version: "v1"} + Expect(instance.Validate()).To(Succeed()) + Expect(instance.Resource).To(Equal("helmswomen")) + }) + + It("should keep the Resource if specified", func() { + instance := &Resource{Group: "crew", Kind: "FirstMate", Version: "v1", Resource: "myresource"} + Expect(instance.Validate()).To(Succeed()) + Expect(instance.Resource).To(Equal("myresource")) + }) + }) + + resources := []*Resource{ + {Group: "crew", Version: "v1", Kind: "FirstMate", Namespaced: true, CreateExampleReconcileBody: true}, + {Group: "ship", Version: "v1beta1", Kind: "Frigate", Namespaced: true, CreateExampleReconcileBody: false}, + {Group: "creatures", Version: "v2alpha1", Kind: "Kraken", Namespaced: false, CreateExampleReconcileBody: false}, + } + + for i := range resources { + r := resources[i] + Describe(fmt.Sprintf("scaffolding API %s", r.Kind), func() { + files := []struct { + instance input.File + file string + }{ + { + file: filepath.Join("pkg", "apis", + fmt.Sprintf("addtoscheme_%s_%s.go", r.Group, r.Version)), + instance: &AddToScheme{Resource: r}, + }, + { + file: filepath.Join("pkg", "apis", r.Group, r.Version, "doc.go"), + instance: &Doc{Resource: r}, + }, + { + file: filepath.Join("pkg", "apis", r.Group, "group.go"), + instance: &Group{Resource: r}, + }, + { + file: filepath.Join("pkg", "apis", r.Group, r.Version, "register.go"), + instance: &Register{Resource: r}, + }, + { + file: filepath.Join("pkg", "apis", r.Group, r.Version, + strings.ToLower(r.Kind)+"_types.go"), + instance: &Types{Resource: r}, + }, + { + file: filepath.Join("pkg", "apis", r.Group, r.Version, + strings.ToLower(r.Kind)+"_types_test.go"), + instance: &TypesTest{Resource: r}, + }, + { + file: filepath.Join("pkg", "apis", r.Group, r.Version, r.Version+"_suite_test.go"), + instance: &VersionSuiteTest{Resource: r}, + }, + { + file: filepath.Join("config", "samples", + fmt.Sprintf("%s_%s_%s.yaml", r.Group, r.Version, strings.ToLower(r.Kind))), + instance: &CRDSample{Resource: r}, + }, + } + + for j := range files { + f := files[j] + Context(f.file, func() { + It("should write a file matching the golden file", func() { + s, result := scaffoldtest.NewTestScaffold(f.file, f.file) + Expect(s.Execute(scaffoldtest.Options(), f.instance)).To(Succeed()) + Expect(result.Actual.String()).To(Equal(result.Golden), result.Actual.String()) + }) + }) + } + }) + } +}) diff --git a/pkg/scaffold/resource/role.go b/pkg/scaffold/resource/role.go new file mode 100644 index 00000000000..faf2738cc95 --- /dev/null +++ b/pkg/scaffold/resource/role.go @@ -0,0 +1,60 @@ +/* +Copyright 2018 The Kubernetes 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 resource + +import ( + "fmt" + "path/filepath" + + "sigs.k8s.io/kubebuilder/pkg/scaffold/input" +) + +var _ input.File = &RoleBinding{} + +// Role scaffolds the config/manager/group_role_rbac.yaml file +type Role struct { + input.Input + + // Resource is a resource in the API group + Resource *Resource +} + +// GetInput implements input.File +func (r *Role) GetInput() (input.Input, error) { + if r.Path == "" { + r.Path = filepath.Join("config", "manager", fmt.Sprintf( + "%s_role_rbac.yaml", r.Resource.Group)) + } + r.TemplateBody = roleTemplate + return r.Input, nil +} + +var roleTemplate = `apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + controller-tools.k8s.io: "1.0" + name: {{.Resource.Group}}-role +rules: +- apiGroups: + - {{ .Resource.Group }}.{{ .Domain }} + resources: + - '*' + verbs: + - '*' + +` diff --git a/pkg/scaffold/resource/rolebinding.go b/pkg/scaffold/resource/rolebinding.go new file mode 100644 index 00000000000..43324667953 --- /dev/null +++ b/pkg/scaffold/resource/rolebinding.go @@ -0,0 +1,60 @@ +/* +Copyright 2018 The Kubernetes 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 resource + +import ( + "fmt" + "path/filepath" + + "sigs.k8s.io/kubebuilder/pkg/scaffold/input" +) + +var _ input.File = &RoleBinding{} + +// RoleBinding scaffolds the config/manager/group_rolebinding_rbac.yaml file +type RoleBinding struct { + input.Input + + // Resource is a resource in the API group + Resource *Resource +} + +// GetInput implements input.File +func (r *RoleBinding) GetInput() (input.Input, error) { + if r.Path == "" { + r.Path = filepath.Join("config", "manager", fmt.Sprintf( + "%s_rolebinding_rbac.yaml", r.Resource.Group)) + } + r.TemplateBody = roleBindingTemplate + return r.Input, nil +} + +var roleBindingTemplate = `apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + labels: + controller-tools.k8s.io: "1.0" + name: {{ .Resource.Group }}-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ .Resource.Group }}-role +subjects: +- kind: ServiceAccount + name: default + namespace: system +` diff --git a/pkg/scaffold/resource/types.go b/pkg/scaffold/resource/types.go new file mode 100644 index 00000000000..0e1c10aef02 --- /dev/null +++ b/pkg/scaffold/resource/types.go @@ -0,0 +1,107 @@ +/* +Copyright 2018 The Kubernetes 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 resource + +import ( + "fmt" + "path/filepath" + "strings" + + "sigs.k8s.io/kubebuilder/pkg/scaffold/input" +) + +var _ input.File = &Types{} + +// Types scaffolds the pkg/apis/group/version/kind_types.go file to define the schema for an API +type Types struct { + input.Input + + // Resource is the resource to scaffold the types_test.go file for + Resource *Resource +} + +// GetInput implements input.File +func (t *Types) GetInput() (input.Input, error) { + if t.Path == "" { + t.Path = filepath.Join("pkg", "apis", t.Resource.Group, t.Resource.Version, + fmt.Sprintf("%s_types.go", strings.ToLower(t.Resource.Kind))) + } + t.TemplateBody = typesTemplate + t.IfExistsAction = input.Error + return t.Input, nil +} + +// Validate validates the values +func (t *Types) Validate() error { + return t.Resource.Validate() +} + +var typesTemplate = `{{ .Boilerplate }} + +package {{ .Resource.Version }} + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// 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. + +// {{.Resource.Kind}}Spec defines the desired state of {{.Resource.Kind}} +type {{.Resource.Kind}}Spec struct { + // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster + // Important: Run "make" to regenerate code after modifying this file +} + +// {{.Resource.Kind}}Status defines the observed state of {{.Resource.Kind}} +type {{.Resource.Kind}}Status struct { + // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster + // Important: Run "make" to regenerate code after modifying this file +} + +// +genclient +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +{{- if not .Resource.Namespaced }} +// +genclient:nonNamespaced +{{- end }} + +// {{.Resource.Kind}} is the Schema for the {{ .Resource.Resource }} API +// +k8s:openapi-gen=true +type {{.Resource.Kind}} struct { + metav1.TypeMeta ` + "`" + `json:",inline"` + "`" + ` + metav1.ObjectMeta ` + "`" + `json:"metadata,omitempty"` + "`" + ` + + Spec {{.Resource.Kind}}Spec ` + "`" + `json:"spec,omitempty"` + "`" + ` + Status {{.Resource.Kind}}Status ` + "`" + `json:"status,omitempty"` + "`" + ` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +{{- if not .Resource.Namespaced }} +// +genclient:nonNamespaced +{{- end }} + +// {{.Resource.Kind}}List contains a list of {{.Resource.Kind}} +type {{.Resource.Kind}}List struct { + metav1.TypeMeta ` + "`" + `json:",inline"` + "`" + ` + metav1.ListMeta ` + "`" + `json:"metadata,omitempty"` + "`" + ` + Items []{{ .Resource.Kind }} ` + "`" + `json:"items"` + "`" + ` +} + +func init() { + SchemeBuilder.Register(&{{.Resource.Kind}}{}, &{{.Resource.Kind}}List{}) +} +` diff --git a/pkg/scaffold/resource/typestest.go b/pkg/scaffold/resource/typestest.go new file mode 100644 index 00000000000..0ee260bed59 --- /dev/null +++ b/pkg/scaffold/resource/typestest.go @@ -0,0 +1,101 @@ +/* +Copyright 2018 The Kubernetes 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 resource + +import ( + "fmt" + "path/filepath" + "strings" + + "sigs.k8s.io/kubebuilder/pkg/scaffold/input" +) + +var _ input.File = &TypesTest{} + +// TypesTest scaffolds the pkg/apis/group/version/kind_types_test.go file to test the API schema +type TypesTest struct { + input.Input + + // Resource is the resource to scaffold the types_test.go file for + Resource *Resource +} + +// GetInput implements input.File +func (t *TypesTest) GetInput() (input.Input, error) { + if t.Path == "" { + t.Path = filepath.Join("pkg", "apis", t.Resource.Group, t.Resource.Version, + fmt.Sprintf("%s_types_test.go", strings.ToLower(t.Resource.Kind))) + } + t.TemplateBody = typesTestTemplate + t.IfExistsAction = input.Error + return t.Input, nil +} + +// Validate validates the values +func (t *TypesTest) Validate() error { + return t.Resource.Validate() +} + +var typesTestTemplate = `{{ .Boilerplate }} + +package {{ .Resource.Version }} + +import ( + "testing" + + "github.com/onsi/gomega" + "golang.org/x/net/context" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +func TestStorage{{ .Resource.Kind }}(t *testing.T) { + key := types.NamespacedName{ + Name: "foo", + {{ if .Resource.Namespaced -}} + Namespace: "default", + {{ end -}} + } + created := &{{ .Resource.Kind }}{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + {{ if .Resource.Namespaced -}} + Namespace: "default", + {{ end -}} + }} + g := gomega.NewGomegaWithT(t) + + // Test Create + fetched := &{{ .Resource.Kind }}{} + g.Expect(c.Create(context.TODO(), created)).NotTo(gomega.HaveOccurred()) + + g.Expect(c.Get(context.TODO(), key, fetched)).NotTo(gomega.HaveOccurred()) + g.Expect(fetched).To(gomega.Equal(created)) + + // Test Updating the Labels + updated := fetched.DeepCopy() + updated.Labels = map[string]string{"hello": "world"} + g.Expect(c.Update(context.TODO(), updated)).NotTo(gomega.HaveOccurred()) + + g.Expect(c.Get(context.TODO(), key, fetched)).NotTo(gomega.HaveOccurred()) + g.Expect(fetched).To(gomega.Equal(updated)) + + // Test Delete + g.Expect(c.Delete(context.TODO(), fetched)).NotTo(gomega.HaveOccurred()) + g.Expect(c.Get(context.TODO(), key, fetched)).To(gomega.HaveOccurred()) +} +` diff --git a/pkg/scaffold/resource/version_suitetest.go b/pkg/scaffold/resource/version_suitetest.go new file mode 100644 index 00000000000..232e75f9e8a --- /dev/null +++ b/pkg/scaffold/resource/version_suitetest.go @@ -0,0 +1,87 @@ +/* +Copyright 2018 The Kubernetes 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 resource + +import ( + "fmt" + "path/filepath" + + "sigs.k8s.io/kubebuilder/pkg/scaffold/input" +) + +var _ input.File = &TypesTest{} + +// VersionSuiteTest scaffolds the version_suite_test.go file to setup the versions test +type VersionSuiteTest struct { + input.Input + + // Resource is the resource to scaffold the types_test.go file for + Resource *Resource +} + +// GetInput implements input.File +func (v *VersionSuiteTest) GetInput() (input.Input, error) { + if v.Path == "" { + v.Path = filepath.Join("pkg", "apis", v.Resource.Group, v.Resource.Version, + fmt.Sprintf("%s_suite_test.go", v.Resource.Version)) + } + v.TemplateBody = versionSuiteTestTemplate + return v.Input, nil +} + +var versionSuiteTestTemplate = `{{ .Boilerplate }} + +package {{ .Resource.Version }} + +import ( + "log" + "os" + "path/filepath" + "testing" + + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" +) + +var cfg *rest.Config +var c client.Client + +func TestMain(m *testing.M) { + t := &envtest.Environment{ + CRDDirectoryPaths: []string{filepath.Join("..", "..", "..", "..", "config", "crds")}, + } + + err := SchemeBuilder.AddToScheme(scheme.Scheme) + if err != nil { + log.Fatal(err) + } + + if cfg, err = t.Start(); err != nil { + log.Fatal(err) + } + + if c, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}); err != nil { + log.Fatal(err) + } + + code := m.Run() + t.Stop() + os.Exit(code) +} +` diff --git a/pkg/scaffold/scaffold.go b/pkg/scaffold/scaffold.go new file mode 100644 index 00000000000..cd05d4d6ef8 --- /dev/null +++ b/pkg/scaffold/scaffold.go @@ -0,0 +1,225 @@ +/* +Copyright 2018 The Kubernetes 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 scaffold + +import ( + "bytes" + "fmt" + "io" + "io/ioutil" + "log" + "os" + "path/filepath" + "strings" + "text/template" + + "golang.org/x/tools/imports" + yaml "gopkg.in/yaml.v2" + "sigs.k8s.io/controller-tools/pkg/util" + "sigs.k8s.io/kubebuilder/pkg/scaffold/input" +) + +// Scaffold writes Templates to scaffold new files +type Scaffold struct { + // BoilerplatePath is the path to the boilerplate file + BoilerplatePath string + + // Boilerplate is the contents of the boilerplate file for code generation + Boilerplate string + + BoilerplateOptional bool + + // Project is the project + Project input.ProjectFile + + ProjectOptional bool + + // ProjectPath is the relative path to the project root + ProjectPath string + + GetWriter func(path string) (io.Writer, error) +} + +func (s *Scaffold) setFieldsAndValidate(t input.File) error { + // Set boilerplate on templates + if b, ok := t.(input.BoilerplatePath); ok { + b.SetBoilerplatePath(s.BoilerplatePath) + } + if b, ok := t.(input.Boilerplate); ok { + b.SetBoilerplate(s.Boilerplate) + } + if b, ok := t.(input.Domain); ok { + b.SetDomain(s.Project.Domain) + } + if b, ok := t.(input.Version); ok { + b.SetVersion(s.Project.Version) + } + if b, ok := t.(input.Repo); ok { + b.SetRepo(s.Project.Repo) + } + if b, ok := t.(input.ProjecPath); ok { + b.SetProjectPath(s.ProjectPath) + } + + // Validate the template is ok + if v, ok := t.(input.Validate); ok { + if err := v.Validate(); err != nil { + return err + } + } + return nil +} + +// GetProject reads the project file and deserializes it into a Project +func getProject(path string) (input.ProjectFile, error) { + in, err := ioutil.ReadFile(path) // nolint: gosec + if err != nil { + return input.ProjectFile{}, err + } + p := input.ProjectFile{} + err = yaml.Unmarshal(in, &p) + if err != nil { + return input.ProjectFile{}, err + } + return p, nil +} + +// GetBoilerplate reads the boilerplate file +func getBoilerplate(path string) (string, error) { + b, err := ioutil.ReadFile(path) // nolint: gosec + return string(b), err +} + +func (s *Scaffold) defaultOptions(options *input.Options) error { + // Use the default Boilerplate path if unset + if options.BoilerplatePath == "" { + options.BoilerplatePath = filepath.Join("hack", "boilerplate.go.txt") + } + + // Use the default Project path if unset + if options.ProjectPath == "" { + options.ProjectPath = "PROJECT" + } + + s.BoilerplatePath = options.BoilerplatePath + + var err error + s.Boilerplate, err = getBoilerplate(options.BoilerplatePath) + if !s.BoilerplateOptional && err != nil { + return err + } + + s.Project, err = getProject(options.ProjectPath) + if !s.ProjectOptional && err != nil { + return err + } + return nil +} + +// Execute executes scaffolding the Files +func (s *Scaffold) Execute(options input.Options, files ...input.File) error { + if s.GetWriter == nil { + s.GetWriter = (&util.FileWriter{}).WriteCloser + } + + if err := s.defaultOptions(&options); err != nil { + return err + } + for _, f := range files { + if err := s.doFile(f); err != nil { + return err + } + } + return nil +} + +// doFile scaffolds a single file +func (s *Scaffold) doFile(e input.File) error { + // Set common fields + err := s.setFieldsAndValidate(e) + if err != nil { + return err + } + + // Get the template input params + i, err := e.GetInput() + if err != nil { + return err + } + + // Check if the file to write already exists + if _, err := os.Stat(i.Path); err == nil { + switch i.IfExistsAction { + case input.Overwrite: + case input.Skip: + return nil + case input.Error: + return fmt.Errorf("%s already exists", i.Path) + } + } + + if err := s.doTemplate(i, e); err != nil { + return err + } + return nil +} + +// doTemplate executes the template for a file using the input +func (s *Scaffold) doTemplate(i input.Input, e input.File) error { + temp, err := newTemplate(e).Parse(i.TemplateBody) + if err != nil { + return err + } + f, err := s.GetWriter(i.Path) + if err != nil { + return err + } + if c, ok := f.(io.Closer); ok { + defer func() { + if err := c.Close(); err != nil { + log.Fatal(err) + } + }() + } + + out := &bytes.Buffer{} + err = temp.Execute(out, e) + if err != nil { + return err + } + b := out.Bytes() + + // gofmt the imports + if filepath.Ext(i.Path) == ".go" { + b, err = imports.Process(i.Path, b, nil) + if err != nil { + fmt.Printf("%s\n", out.Bytes()) + return err + } + } + + _, err = f.Write(b) + return err +} + +// newTemplate a new template with common functions +func newTemplate(t input.File) *template.Template { + return template.New(fmt.Sprintf("%T", t)).Funcs(template.FuncMap{ + "title": strings.Title, + "lower": strings.ToLower, + }) +} diff --git a/pkg/scaffold/scaffold_suite_test.go b/pkg/scaffold/scaffold_suite_test.go new file mode 100644 index 00000000000..1841858e740 --- /dev/null +++ b/pkg/scaffold/scaffold_suite_test.go @@ -0,0 +1,13 @@ +package scaffold_test + +import ( + "testing" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +func TestScaffold(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Scaffold Suite") +} diff --git a/pkg/scaffold/scaffold_test.go b/pkg/scaffold/scaffold_test.go new file mode 100644 index 00000000000..533bf232d63 --- /dev/null +++ b/pkg/scaffold/scaffold_test.go @@ -0,0 +1,9 @@ +package scaffold_test + +import ( + . "github.com/onsi/ginkgo" +) + +var _ = Describe("Scaffold", func() { + +}) diff --git a/pkg/scaffold/scaffoldtest/scaffoldtest.go b/pkg/scaffold/scaffoldtest/scaffoldtest.go new file mode 100644 index 00000000000..d8b00fa6be5 --- /dev/null +++ b/pkg/scaffold/scaffoldtest/scaffoldtest.go @@ -0,0 +1,86 @@ +/* +Copyright 2018 The Kubernetes 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 scaffoldtest + +import ( + "bytes" + "io" + "io/ioutil" + "path/filepath" + + "github.com/onsi/ginkgo" + "github.com/onsi/gomega" + "sigs.k8s.io/kubebuilder/pkg/scaffold" + "sigs.k8s.io/kubebuilder/pkg/scaffold/input" + "sigs.k8s.io/kubebuilder/pkg/scaffold/project/projectutil" +) + +// TestResult is the result of running the scaffolding. +type TestResult struct { + // Actual is the bytes written to a scaffolded file. + Actual bytes.Buffer + + // Golden is the golden file contents read from the controller-tools/test package + Golden string +} + +// ProjectPath is the path to the controller-tools/test project file +func ProjectPath() string { + root, err := projectutil.GetProjectDir() + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + return filepath.Join(root, "test", "PROJECT") +} + +// BoilerplatePath is the path to the controller-tools/test boilerplate file +func BoilerplatePath() string { + root, err := projectutil.GetProjectDir() + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + return filepath.Join(root, "test", "hack", "boilerplate.go.txt") +} + +// Options are the options for scaffolding in the controller-tools/test directory +func Options() input.Options { + return input.Options{ + BoilerplatePath: BoilerplatePath(), + ProjectPath: ProjectPath(), + } +} + +// NewTestScaffold returns a new Scaffold and TestResult instance for testing +func NewTestScaffold(writeToPath, goldenPath string) (*scaffold.Scaffold, *TestResult) { + r := &TestResult{} + + root, err := projectutil.GetProjectDir() + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + // Setup scaffold + s := &scaffold.Scaffold{ + GetWriter: func(path string) (io.Writer, error) { + defer ginkgo.GinkgoRecover() + gomega.Expect(path).To(gomega.Equal(writeToPath)) + return &r.Actual, nil + }, + ProjectPath: filepath.Join(root, "test"), + } + + if len(goldenPath) > 0 { + b, err := ioutil.ReadFile(filepath.Join(root, "test", goldenPath)) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + r.Golden = string(b) + } + return s, r +} diff --git a/pkg/scaffold/webhook/add_admissionbuilder_handler.go b/pkg/scaffold/webhook/add_admissionbuilder_handler.go new file mode 100644 index 00000000000..e8cd9f4a47e --- /dev/null +++ b/pkg/scaffold/webhook/add_admissionbuilder_handler.go @@ -0,0 +1,86 @@ +/* +Copyright 2018 The Kubernetes 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 webhook + +import ( + "fmt" + "path/filepath" + "strings" + + "sigs.k8s.io/kubebuilder/pkg/scaffold/input" + "sigs.k8s.io/kubebuilder/pkg/scaffold/resource" +) + +var _ input.File = &AddAdmissionWebhookBuilderHandler{} + +// AddAdmissionWebhookBuilderHandler scaffolds adds a new admission webhook builder. +type AddAdmissionWebhookBuilderHandler struct { + input.Input + + // Resource is a resource in the API group + Resource *resource.Resource + + Config +} + +// GetInput implements input.File +func (a *AddAdmissionWebhookBuilderHandler) GetInput() (input.Input, error) { + a.Server = strings.ToLower(a.Server) + if a.Path == "" { + a.Path = filepath.Join("pkg", "webhook", + fmt.Sprintf("%s_server", a.Server), + fmt.Sprintf("add_%s_%s.go", a.Type, strings.ToLower(a.Resource.Kind))) + } + a.TemplateBody = addAdmissionWebhookBuilderHandlerTemplate + return a.Input, nil +} + +var addAdmissionWebhookBuilderHandlerTemplate = `{{ .Boilerplate }} + +package {{ .Server }}server + +import ( + "fmt" + + "{{ .Repo }}/pkg/webhook/{{ .Server }}_server/{{ .Resource.Resource }}/{{ .Type }}" +) + +func init() { + for k, v := range {{ .Type }}.Builders { + _, found := builderMap[k] + if found { + log.V(1).Info(fmt.Sprintf( + "conflicting webhook builder names in builder map: %v", k)) + } + builderMap[k] = v + } + for k, v := range {{ .Type }}.HandlerMap { + _, found := HandlerMap[k] + if found { + log.V(1).Info(fmt.Sprintf( + "conflicting webhook builder names in handler map: %v", k)) + } + _, found = builderMap[k] + if !found { + log.V(1).Info(fmt.Sprintf( + "can't find webhook builder name %q in builder map", k)) + continue + } + HandlerMap[k] = v + } +} +` diff --git a/pkg/scaffold/webhook/add_server.go b/pkg/scaffold/webhook/add_server.go new file mode 100644 index 00000000000..acf754b81bc --- /dev/null +++ b/pkg/scaffold/webhook/add_server.go @@ -0,0 +1,60 @@ +/* +Copyright 2018 The Kubernetes 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 webhook + +import ( + "fmt" + "path/filepath" + + "sigs.k8s.io/kubebuilder/pkg/scaffold/input" + "sigs.k8s.io/kubebuilder/pkg/scaffold/resource" +) + +var _ input.File = &AddServer{} + +// AddServer scaffolds adds a new webhook server. +type AddServer struct { + input.Input + + // Resource is a resource in the API group + Resource *resource.Resource + + Config +} + +// GetInput implements input.File +func (a *AddServer) GetInput() (input.Input, error) { + if a.Path == "" { + a.Path = filepath.Join("pkg", "webhook", fmt.Sprintf("add_%s_server.go", a.Server)) + } + a.TemplateBody = addServerTemplate + return a.Input, nil +} + +var addServerTemplate = `{{ .Boilerplate }} + +package webhook + +import ( + server "{{ .Repo }}/pkg/webhook/{{ .Server }}_server" +) + +func init() { + // AddToManagerFuncs is a list of functions to create webhook servers and add them to a manager. + AddToManagerFuncs = append(AddToManagerFuncs, server.Add) +} +` diff --git a/pkg/scaffold/webhook/admissionbuilder.go b/pkg/scaffold/webhook/admissionbuilder.go new file mode 100644 index 00000000000..852bb6647ae --- /dev/null +++ b/pkg/scaffold/webhook/admissionbuilder.go @@ -0,0 +1,101 @@ +/* +Copyright 2018 The Kubernetes 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 webhook + +import ( + "fmt" + "path/filepath" + "strings" + + "sigs.k8s.io/kubebuilder/pkg/scaffold/input" + "sigs.k8s.io/kubebuilder/pkg/scaffold/resource" +) + +var _ input.File = &AdmissionWebhookBuilder{} + +// AdmissionWebhookBuilder scaffolds adds a new webhook server. +type AdmissionWebhookBuilder struct { + input.Input + + // Resource is a resource in the API group + Resource *resource.Resource + + // ResourcePackage is the package of the Resource + ResourcePackage string + + // GroupDomain is the Group + "." + Domain for the Resource + GroupDomain string + + Config + + BuilderName string + + OperationsParameterString string + + Mutating bool +} + +// GetInput implements input.File +func (a *AdmissionWebhookBuilder) GetInput() (input.Input, error) { + a.ResourcePackage, a.GroupDomain = getResourceInfo(coreGroups, a.Resource, a.Input) + + if a.Type == "mutating" { + a.Mutating = true + } + a.Type = strings.ToLower(a.Type) + a.BuilderName = builderName(a.Config, a.Resource.Resource) + ops := make([]string, len(a.Operations)) + for i, op := range a.Operations { + ops[i] = "admissionregistrationv1beta1." + strings.Title(op) + } + a.OperationsParameterString = strings.Join(ops, ", ") + + if a.Path == "" { + a.Path = filepath.Join("pkg", "webhook", + fmt.Sprintf("%s_server", a.Server), + a.Resource.Resource, + a.Type, + fmt.Sprintf("%s_webhook.go", strings.Join(a.Operations, "_"))) + } + a.TemplateBody = admissionWebhookBuilderTemplate + return a.Input, nil +} + +var admissionWebhookBuilderTemplate = `{{ .Boilerplate }} + +package {{ .Type }} + +import ( + {{ .Resource.Group}}{{ .Resource.Version }} "{{ .ResourcePackage }}/{{ .Resource.Group}}/{{ .Resource.Version }}" + admissionregistrationv1beta1 "k8s.io/api/admissionregistration/v1beta1" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission/builder" +) + +func init() { + builderName := "{{ .BuilderName }}" + Builders[builderName] = builder. + NewWebhookBuilder(). + Name(builderName + ".{{ .Domain }}"). + Path("/" + builderName). +{{ if .Mutating }} Mutating(). +{{ else }} Validating(). +{{ end }} + Operations({{ .OperationsParameterString }}). + FailurePolicy(admissionregistrationv1beta1.Fail). + ForType(&{{ .Resource.Group}}{{ .Resource.Version }}.{{ .Resource.Kind }}{}) +} +` diff --git a/pkg/scaffold/webhook/admissionhandler.go b/pkg/scaffold/webhook/admissionhandler.go new file mode 100644 index 00000000000..add65cc5ef0 --- /dev/null +++ b/pkg/scaffold/webhook/admissionhandler.go @@ -0,0 +1,168 @@ +/* +Copyright 2018 The Kubernetes 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 webhook + +import ( + "fmt" + "path/filepath" + "strings" + + "sigs.k8s.io/kubebuilder/pkg/scaffold/input" + "sigs.k8s.io/kubebuilder/pkg/scaffold/resource" +) + +var _ input.File = &AddAdmissionWebhookBuilderHandler{} + +// AdmissionHandler scaffolds an admission handler +type AdmissionHandler struct { + input.Input + + // ResourcePackage is the package of the Resource + ResourcePackage string + + // GroupDomain is the Group + "." + Domain for the Resource + GroupDomain string + + // Resource is a resource in the API group + Resource *resource.Resource + + Config + + BuilderName string + + OperationsString string + + Mutate bool +} + +// GetInput implements input.File +func (a *AdmissionHandler) GetInput() (input.Input, error) { + a.ResourcePackage, a.GroupDomain = getResourceInfo(coreGroups, a.Resource, a.Input) + a.Type = strings.ToLower(a.Type) + if a.Type == "mutating" { + a.Mutate = true + } + a.BuilderName = builderName(a.Config, a.Resource.Resource) + ops := make([]string, len(a.Operations)) + for i, op := range a.Operations { + ops[i] = strings.Title(op) + } + a.OperationsString = strings.Join(ops, "") + + if a.Path == "" { + a.Path = filepath.Join("pkg", "webhook", + fmt.Sprintf("%s_server", a.Server), + a.Resource.Resource, + a.Type, + fmt.Sprintf("%s_%s_handler.go", a.Resource.Resource, strings.Join(a.Operations, "_"))) + } + a.TemplateBody = addAdmissionHandlerTemplate + return a.Input, nil +} + +var addAdmissionHandlerTemplate = `{{ .Boilerplate }} + +package {{ .Type }} + +import ( + "context" + "net/http" + + {{ .Resource.Group}}{{ .Resource.Version }} "{{ .ResourcePackage }}/{{ .Resource.Group}}/{{ .Resource.Version }}" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/runtime/inject" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission/types" +) + +func init() { + webhookName := "{{ .BuilderName }}" + if HandlerMap[webhookName] == nil { + HandlerMap[webhookName] = []admission.Handler{} + } + HandlerMap[webhookName] = append(HandlerMap[webhookName], &{{ .Resource.Kind }}{{ .OperationsString }}Handler{}) +} + +// {{ .Resource.Kind }}{{ .OperationsString }}Handler handles {{ .Resource.Kind }} +type {{ .Resource.Kind }}{{ .OperationsString }}Handler struct { + // Client client.Client + + // Decoder decodes objects + Decoder types.Decoder +} +{{ if .Mutate }} +func (h *{{ .Resource.Kind }}{{ .OperationsString }}Handler) {{ .Type }}{{ .Resource.Kind }}Fn(ctx context.Context, obj *{{ .Resource.Group}}{{ .Resource.Version }}.{{ .Resource.Kind }}) error { + // TODO(user): implement your admission logic + return nil +} +{{ else }} +func (h *{{ .Resource.Kind }}{{ .OperationsString }}Handler) {{ .Type }}{{ .Resource.Kind }}Fn(ctx context.Context, obj *{{ .Resource.Group}}{{ .Resource.Version }}.{{ .Resource.Kind }}) (bool, string, error) { + // TODO(user): implement your admission logic + return true, "allowed to be admitted", nil +} +{{ end }} +var _ admission.Handler = &{{ .Resource.Kind }}{{ .OperationsString }}Handler{} +{{ if .Mutate }} +// Handle handles admission requests. +func (h *{{ .Resource.Kind }}{{ .OperationsString }}Handler) Handle(ctx context.Context, req types.Request) types.Response { + obj := &{{ .Resource.Group}}{{ .Resource.Version }}.{{ .Resource.Kind }}{} + + err := h.Decoder.Decode(req, obj) + if err != nil { + return admission.ErrorResponse(http.StatusBadRequest, err) + } + copy := obj.DeepCopy() + + err = h.{{ .Type }}{{ .Resource.Kind }}Fn(ctx, copy) + if err != nil { + return admission.ErrorResponse(http.StatusInternalServerError, err) + } + return admission.PatchResponse(obj, copy) +} +{{ else }} +// Handle handles admission requests. +func (h *{{ .Resource.Kind }}{{ .OperationsString }}Handler) Handle(ctx context.Context, req types.Request) types.Response { + obj := &{{ .Resource.Group}}{{ .Resource.Version }}.{{ .Resource.Kind }}{} + + err := h.Decoder.Decode(req, obj) + if err != nil { + return admission.ErrorResponse(http.StatusBadRequest, err) + } + + allowed, reason, err := h.{{ .Type }}{{ .Resource.Kind }}Fn(ctx, obj) + if err != nil { + return admission.ErrorResponse(http.StatusInternalServerError, err) + } + return admission.ValidationResponse(allowed, reason) +} +{{ end }} +//var _ inject.Client = &{{ .Resource.Kind }}{{ .OperationsString }}Handler{} +// +//// InjectClient injects the client into the {{ .Resource.Kind }}{{ .OperationsString }}Handler +//func (h *{{ .Resource.Kind }}{{ .OperationsString }}Handler) InjectClient(c client.Client) error { +// h.Client = c +// return nil +//} + +var _ inject.Decoder = &{{ .Resource.Kind }}{{ .OperationsString }}Handler{} + +// InjectDecoder injects the decoder into the {{ .Resource.Kind }}{{ .OperationsString }}Handler +func (h *{{ .Resource.Kind }}{{ .OperationsString }}Handler) InjectDecoder(d types.Decoder) error { + h.Decoder = d + return nil +} +` diff --git a/pkg/scaffold/webhook/admissionwebhooks.go b/pkg/scaffold/webhook/admissionwebhooks.go new file mode 100644 index 00000000000..0360d9dfdab --- /dev/null +++ b/pkg/scaffold/webhook/admissionwebhooks.go @@ -0,0 +1,69 @@ +/* +Copyright 2018 The Kubernetes 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 webhook + +import ( + "fmt" + "path/filepath" + "strings" + + "sigs.k8s.io/kubebuilder/pkg/scaffold/input" + "sigs.k8s.io/kubebuilder/pkg/scaffold/resource" +) + +var _ input.File = &Server{} + +// AdmissionWebhooks scaffolds how to construct a webhook server and register webhooks. +type AdmissionWebhooks struct { + input.Input + + // Resource is a resource in the API group + Resource *resource.Resource + + Config +} + +// GetInput implements input.File +func (a *AdmissionWebhooks) GetInput() (input.Input, error) { + a.Server = strings.ToLower(a.Server) + a.Type = strings.ToLower(a.Type) + if a.Path == "" { + a.Path = filepath.Join("pkg", "webhook", + fmt.Sprintf("%s_server", a.Server), + strings.ToLower(a.Resource.Resource), + a.Type, "webhooks.go") + } + a.TemplateBody = webhooksTemplate + return a.Input, nil +} + +var webhooksTemplate = `{{ .Boilerplate }} + +package {{ .Type }} + +import ( + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission/builder" +) + +var ( + // Builders contain admission webhook builders + Builders = map[string]*builder.WebhookBuilder{} + // HandlerMap contains admission webhook handlers + HandlerMap = map[string][]admission.Handler{} +) +` diff --git a/pkg/scaffold/webhook/config.go b/pkg/scaffold/webhook/config.go new file mode 100644 index 00000000000..dd48bf5682b --- /dev/null +++ b/pkg/scaffold/webhook/config.go @@ -0,0 +1,27 @@ +/* +Copyright 2018 The Kubernetes 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 webhook + +// Config contains the information required to scaffold files for a webhook. +type Config struct { + // Server is the name of the server. + Server string + // Type is the type of the webhook. + Type string + // Operations that the webhook will intercept, e.g. create, update, delete, connect + Operations []string +} diff --git a/pkg/scaffold/webhook/server.go b/pkg/scaffold/webhook/server.go new file mode 100644 index 00000000000..9384ed77d1a --- /dev/null +++ b/pkg/scaffold/webhook/server.go @@ -0,0 +1,125 @@ +/* +Copyright 2018 The Kubernetes 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 webhook + +import ( + "fmt" + "path/filepath" + + "sigs.k8s.io/kubebuilder/pkg/scaffold/input" + "sigs.k8s.io/kubebuilder/pkg/scaffold/resource" +) + +var _ input.File = &Server{} + +// Server scaffolds how to construct a webhook server and register webhooks. +type Server struct { + input.Input + + // Resource is a resource in the API group + Resource *resource.Resource + + Config +} + +// GetInput implements input.File +func (a *Server) GetInput() (input.Input, error) { + if a.Path == "" { + a.Path = filepath.Join("pkg", "webhook", fmt.Sprintf("%s_server", a.Server), "server.go") + } + a.TemplateBody = serverTemplate + return a.Input, nil +} + +var serverTemplate = `{{ .Boilerplate }} + +package {{ .Server }}server + +import ( + "fmt" + "os" + + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/manager" + logf "sigs.k8s.io/controller-runtime/pkg/runtime/log" + "sigs.k8s.io/controller-runtime/pkg/webhook" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission/builder" +) + +var ( + log = logf.Log.WithName("{{ .Server }}_server") + builderMap = map[string]*builder.WebhookBuilder{} + // HandlerMap contains all admission webhook handlers. + HandlerMap = map[string][]admission.Handler{} +) + +// Add adds itself to the manager +func Add(mgr manager.Manager) error { + ns := os.Getenv("POD_NAMESPACE") + if len(ns) == 0 { + ns = "default" + } + secretName := os.Getenv("SECRET_NAME") + if len(secretName) == 0 { + secretName = "webhook-server-secret" + } + + svr, err := webhook.NewServer("foo-admission-server", mgr, webhook.ServerOptions{ + // TODO(user): change the configuration of ServerOptions based on your need. + Port: 9876, + CertDir: "/tmp/cert", + BootstrapOptions: &webhook.BootstrapOptions{ + Secret: &types.NamespacedName{ + Namespace: ns, + Name: secretName, + }, + + Service: &webhook.Service{ + Namespace: ns, + Name: "webhook-server-service", + // Selectors should select the pods that runs this webhook server. + Selectors: map[string]string{ + "control-plane": "controller-manager", + }, + }, + }, + }) + if err != nil { + return err + } + + var webhooks []webhook.Webhook + for k, builder := range builderMap { + handlers, ok := HandlerMap[k] + if !ok { + log.V(1).Info(fmt.Sprintf("can't find handlers for builder: %v", k)) + handlers = []admission.Handler{} + } + wh, err := builder. + Handlers(handlers...). + WithManager(mgr). + Build() + if err != nil { + return err + } + webhooks = append(webhooks, wh) + } + + return svr.Register(webhooks...) +} +` diff --git a/pkg/scaffold/webhook/util.go b/pkg/scaffold/webhook/util.go new file mode 100644 index 00000000000..089d1b6be6d --- /dev/null +++ b/pkg/scaffold/webhook/util.go @@ -0,0 +1,67 @@ +/* +Copyright 2018 The Kubernetes 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 webhook + +import ( + "fmt" + "os" + "path" + "path/filepath" + "strings" + + "sigs.k8s.io/kubebuilder/pkg/scaffold/input" + "sigs.k8s.io/kubebuilder/pkg/scaffold/resource" +) + +// Use the k8s.io/api package for core resources +var coreGroups = map[string]string{ + "apps": "", + "admissionregistration": "k8s.io", + "apiextensions": "k8s.io", + "authentication": "k8s.io", + "autoscaling": "", + "batch": "", + "certificates": "k8s.io", + "core": "", + "extensions": "", + "metrics": "k8s.io", + "policy": "", + "rbac.authorization": "k8s.io", + "storage": "k8s.io", +} + +func builderName(config Config, resource string) string { + opsStr := strings.Join(config.Operations, "-") + return fmt.Sprintf("%s-%s-%s", config.Type, opsStr, resource) +} + +func getResourceInfo(coreGroups map[string]string, r *resource.Resource, in input.Input) (resourcePackage, groupDomain string) { + resourcePath := filepath.Join("pkg", "apis", r.Group, r.Version, + fmt.Sprintf("%s_types.go", strings.ToLower(r.Kind))) + if _, err := os.Stat(resourcePath); os.IsNotExist(err) { + if domain, found := coreGroups[r.Group]; found { + resourcePackage := path.Join("k8s.io", "api") + groupDomain = r.Group + if domain != "" { + groupDomain = r.Group + "." + domain + } + return resourcePackage, groupDomain + } + // TODO: need to support '--resource-pkg-path' flag for specifying resourcePath + } + return path.Join(in.Repo, "pkg", "apis"), r.Group + "." + in.Domain +}