Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ Added support for empty group in kubebuilder api creation with v3+ #1404 #1679

Merged
merged 1 commit into from
Sep 25, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion generate_testdata.sh
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,11 @@ scaffold_test_project() {
$kb create api --group sea-creatures --version v1beta1 --kind Kraken --controller=true --resource=true --make=false
$kb create api --group sea-creatures --version v1beta2 --kind Leviathan --controller=true --resource=true --make=false
$kb create api --group foo.policy --version v1 --kind HealthCheckPolicy --controller=true --resource=true --make=false
elif [ $project == "project-v2-addon" ] || [ $project == "project-v3-addon" ]; then
if [ $project == "project-v3-multigroup" ]; then
$kb create api --version v1 --kind Lakers --controller=true --resource=true --make=false
$kb create webhook --version v1 --kind Lakers --defaulting --programmatic-validation
fi
elif [ $project == "project-v2-addon" ] || [ $project == "project-v3-addon" ]; then
header_text 'enabling --pattern flag ...'
export KUBEBUILDER_ENABLE_PLUGINS=1
prafull01 marked this conversation as resolved.
Show resolved Hide resolved
header_text 'Creating APIs ...'
Expand Down
71 changes: 65 additions & 6 deletions pkg/model/resource/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,7 @@ import (
)

const (
versionPattern = "^v\\d+(alpha\\d+|beta\\d+)?$"

versionPattern = "^v\\d+(alpha\\d+|beta\\d+)?$"
groupRequired = "group cannot be empty"
versionRequired = "version cannot be empty"
kindRequired = "kind cannot be empty"
Expand Down Expand Up @@ -85,8 +84,8 @@ type Options struct {
Namespaced bool
}

// Validate verifies that all the fields have valid values
func (opts *Options) Validate() error {
// ValidateV2 verifies that V2 project has all the fields have valid values
func (opts *Options) ValidateV2() error {
// Check that the required flags did not get a flag as their value
// We can safely look for a '-' as the first char as none of the fields accepts it
// NOTE: We must do this for all the required flags first or we may output the wrong
Expand Down Expand Up @@ -139,6 +138,56 @@ func (opts *Options) Validate() error {
return nil
}

// Validate verifies that all the fields have valid values
func (opts *Options) Validate() error {
// Check that the required flags did not get a flag as their value
// We can safely look for a '-' as the first char as none of the fields accepts it
// NOTE: We must do this for all the required flags first or we may output the wrong
// error as flags may seem to be missing because Cobra assigned them to another flag.
if strings.HasPrefix(opts.Version, "-") {
return fmt.Errorf(versionRequired)
}
if strings.HasPrefix(opts.Kind, "-") {
return fmt.Errorf(kindRequired)
}
// Now we can check that all the required flags are not empty
if len(opts.Version) == 0 {
return fmt.Errorf(versionRequired)
}
if len(opts.Kind) == 0 {
return fmt.Errorf(kindRequired)
}

// Check if the Group has a valid DNS1123 subdomain value
if len(opts.Group) != 0 {
if err := validation.IsDNS1123Subdomain(opts.Group); err != nil {
return fmt.Errorf("group name is invalid: (%v)", err)
}
}

// Check if the version follows the valid pattern
if !versionRegex.MatchString(opts.Version) {
return fmt.Errorf("version must match %s (was %s)", versionPattern, opts.Version)
}

validationErrors := []string{}

// require Kind to start with an uppercase character
if string(opts.Kind[0]) == strings.ToLower(string(opts.Kind[0])) {
validationErrors = append(validationErrors, "kind must start with an uppercase character")
}

validationErrors = append(validationErrors, validation.IsDNS1035Label(strings.ToLower(opts.Kind))...)

if len(validationErrors) != 0 {
return fmt.Errorf("invalid Kind: %#v", validationErrors)
}

// TODO: validate plural strings if provided

return nil
}

// GVK returns the group-version-kind information to check against tracked resources in the configuration file
func (opts *Options) GVK() config.GVK {
return config.GVK{
Expand Down Expand Up @@ -167,7 +216,11 @@ func (opts *Options) NewResource(c *config.Config, doResource bool) *Resource {

pkg := replacer.Replace(path.Join(c.Repo, "api", "%[version]"))
if c.MultiGroup {
pkg = replacer.Replace(path.Join(c.Repo, "apis", "%[group]", "%[version]"))
if opts.Group != "" {
pkg = replacer.Replace(path.Join(c.Repo, "apis", "%[group]", "%[version]"))
} else {
pkg = replacer.Replace(path.Join(c.Repo, "apis", "%[version]"))
}
}
prafull01 marked this conversation as resolved.
Show resolved Hide resolved
domain := c.Domain

Expand All @@ -188,8 +241,14 @@ func (opts *Options) NewResource(c *config.Config, doResource bool) *Resource {

res.Package = pkg
res.Domain = opts.Group
if domain != "" {
if domain != "" && opts.Group != "" {
res.Domain += "." + domain
} else if opts.Group == "" && !c.IsV2() {
// Empty group overrides the default values provided by newResource().
// GroupPackageName and ImportAlias includes domain instead of group name as user provided group is empty.
res.Domain = domain
res.GroupPackageName = opts.safeImport(domain)
res.ImportAlias = opts.safeImport(domain + opts.Version)
}

return res
Expand Down
108 changes: 105 additions & 3 deletions pkg/model/resource/options_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,9 @@ var _ = Describe("Resource Options", func() {
Expect(options.Validate()).To(Succeed())
})

It("should fail if the Group is not specified", func() {
It("should succeed if the Group is not specified for V3", func() {
options := &Options{Version: "v1", Kind: "FirstMate"}
Expect(options.Validate()).NotTo(Succeed())
Expect(options.Validate().Error()).To(ContainSubstring("group cannot be empty"))
Expect(options.Validate()).To(Succeed())
prafull01 marked this conversation as resolved.
Show resolved Hide resolved
})

It("should fail if the Group is not all lowercase", func() {
Expand All @@ -43,6 +42,7 @@ var _ = Describe("Resource Options", func() {
Expect(options.Validate().Error()).To(ContainSubstring("version cannot be empty"))
})

//nolint:dupl
It("should fail if the Version does not match the version format", func() {
options := &Options{Group: "crew", Version: "1", Kind: "FirstMate"}
Expect(options.Validate()).NotTo(Succeed())
Expand Down Expand Up @@ -110,4 +110,106 @@ var _ = Describe("Resource Options", func() {
Expect(err).To(MatchError(ContainSubstring("kind must start with an uppercase character")))
})
})

// We are duplicating the test cases for ValidateV2 with the Validate(). This test cases will be removed when
// the V2 will no longer be supported.
Describe("scaffolding an API for V2", func() {
It("should succeed if the Options is valid for V2", func() {
options := &Options{Group: "crew", Version: "v1", Kind: "FirstMate"}
Expect(options.ValidateV2()).To(Succeed())
})

It("should not succeed if the Group is not specified for V2", func() {
options := &Options{Version: "v1", Kind: "FirstMate"}
Expect(options.ValidateV2()).NotTo(Succeed())
Expect(options.ValidateV2().Error()).To(ContainSubstring("group cannot be empty"))
})

It("should fail if the Group is not all lowercase for V2", func() {
options := &Options{Group: "Crew", Version: "v1", Kind: "FirstMate"}
Expect(options.ValidateV2()).NotTo(Succeed())
Expect(options.ValidateV2().Error()).To(ContainSubstring("group name is invalid: " +
"([a DNS-1123 subdomain must consist of lower case alphanumeric characters"))
})

It("should fail if the Group contains non-alpha characters for V2", func() {
options := &Options{Group: "crew1*?", Version: "v1", Kind: "FirstMate"}
Expect(options.ValidateV2()).NotTo(Succeed())
Expect(options.ValidateV2().Error()).To(ContainSubstring("group name is invalid: " +
"([a DNS-1123 subdomain must consist of lower case alphanumeric characters"))
})

It("should fail if the Version is not specified for V2", func() {
options := &Options{Group: "crew", Kind: "FirstMate"}
Expect(options.ValidateV2()).NotTo(Succeed())
Expect(options.ValidateV2().Error()).To(ContainSubstring("version cannot be empty"))
})
//nolint:dupl
It("should fail if the Version does not match the version format for V2", func() {
options := &Options{Group: "crew", Version: "1", Kind: "FirstMate"}
Expect(options.ValidateV2()).NotTo(Succeed())
Expect(options.ValidateV2().Error()).To(ContainSubstring(
`version must match ^v\d+(alpha\d+|beta\d+)?$ (was 1)`))

options = &Options{Group: "crew", Version: "1beta1", Kind: "FirstMate"}
Expect(options.ValidateV2()).NotTo(Succeed())
Expect(options.ValidateV2().Error()).To(ContainSubstring(
`version must match ^v\d+(alpha\d+|beta\d+)?$ (was 1beta1)`))

options = &Options{Group: "crew", Version: "a1beta1", Kind: "FirstMate"}
Expect(options.ValidateV2()).NotTo(Succeed())
Expect(options.ValidateV2().Error()).To(ContainSubstring(
`version must match ^v\d+(alpha\d+|beta\d+)?$ (was a1beta1)`))

options = &Options{Group: "crew", Version: "v1beta", Kind: "FirstMate"}
Expect(options.ValidateV2()).NotTo(Succeed())
Expect(options.ValidateV2().Error()).To(ContainSubstring(
`version must match ^v\d+(alpha\d+|beta\d+)?$ (was v1beta)`))

options = &Options{Group: "crew", Version: "v1beta1alpha1", Kind: "FirstMate"}
Expect(options.ValidateV2()).NotTo(Succeed())
Expect(options.ValidateV2().Error()).To(ContainSubstring(
`version must match ^v\d+(alpha\d+|beta\d+)?$ (was v1beta1alpha1)`))
})

It("should fail if the Kind is not specified for V2", func() {
options := &Options{Group: "crew", Version: "v1"}
Expect(options.ValidateV2()).NotTo(Succeed())
Expect(options.ValidateV2().Error()).To(ContainSubstring("kind cannot be empty"))
})

DescribeTable("valid Kind values-according to core Kubernetes for V2",
func(kind string) {
options := &Options{Group: "crew", Kind: kind, Version: "v1"}
Expect(options.ValidateV2()).To(Succeed())
},
Entry("should pass validation if Kind is camelcase", "FirstMate"),
Entry("should pass validation if Kind has more than one caps at the start", "FIRSTMate"),
)

It("should fail if Kind is too long for V2", func() {
kind := strings.Repeat("a", 64)

options := &Options{Group: "crew", Kind: kind, Version: "v1"}
err := options.ValidateV2()
Expect(err).To(MatchError(ContainSubstring("must be no more than 63 characters")))
})

DescribeTable("invalid Kind values-according to core Kubernetes for V2",
func(kind string) {
options := &Options{Group: "crew", Kind: kind, Version: "v1"}
Expect(options.ValidateV2()).To(MatchError(
ContainSubstring("a DNS-1035 label must consist of lower case alphanumeric characters")))
},
Entry("should fail validation if Kind contains whitespaces", "Something withSpaces"),
Entry("should fail validation if Kind ends in -", "KindEndingIn-"),
Entry("should fail validation if Kind starts with number", "0ValidityKind"),
)

It("should fail if Kind starts with a lowercase character for V2", func() {
options := &Options{Group: "crew", Kind: "lOWERCASESTART", Version: "v1"}
err := options.ValidateV2()
Expect(err).To(MatchError(ContainSubstring("kind must start with an uppercase character")))
})
})
})
23 changes: 23 additions & 0 deletions pkg/model/resource/resource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ var _ = Describe("Resource", func() {
Expect(options.Validate()).To(Succeed())

resource := options.NewResource(singleGroupConfig, true)

Expect(resource.Group).To(Equal(options.Group))
Expect(resource.GroupPackageName).To(Equal("myproject"))
Expect(resource.ImportAlias).To(Equal("myprojectv1"))
Expand Down Expand Up @@ -234,5 +235,27 @@ var _ = Describe("Resource", func() {
Expect(resource.Package).To(Equal(path.Join("k8s.io", "api", options.Group, options.Version)))
Expect(resource.Domain).To(Equal("authentication.k8s.io"))
})
It("should use domain if the group is empty for version v3", func() {
options := &Options{Version: "v1", Kind: "FirstMate"}
Expect(options.Validate()).To(Succeed())

resource := options.NewResource(
&config.Config{
Version: config.Version3Alpha,
Domain: "test.io",
Repo: "test",
},
true,
)
Expect(resource.Namespaced).To(Equal(options.Namespaced))
Expect(resource.Group).To(Equal(""))
Expect(resource.GroupPackageName).To(Equal("testio"))
Expect(resource.Version).To(Equal(options.Version))
Expect(resource.Kind).To(Equal(options.Kind))
Expect(resource.Plural).To(Equal("firstmates"))
Expect(resource.ImportAlias).To(Equal("testiov1"))
Expect(resource.Package).To(Equal(path.Join("test", "api", "v1")))
Expect(resource.Domain).To(Equal("test.io"))
})
})
})
2 changes: 1 addition & 1 deletion pkg/plugin/v2/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ func (p *createAPIPlugin) Run() error {
}

func (p *createAPIPlugin) Validate() error {
if err := p.resource.Validate(); err != nil {
if err := p.resource.ValidateV2(); err != nil {
return err
}

Expand Down
4 changes: 4 additions & 0 deletions pkg/plugin/v3/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,10 @@ func (p *createAPIPlugin) Validate() error {
return err
}

if p.resource.Group == "" && p.config.Domain == "" {
Copy link
Contributor

@gabbifish gabbifish Sep 22, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this check is redundant, given that p.resource.Validate() (called above) never allows p.Config.Domain to be an empty string in the first place?

Copy link
Contributor Author

@prafull01 prafull01 Sep 22, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gabbifish IMO this is a necessary check, while initializing a project (kb init) you can provide a domain which can be empty.

Validate() function doesn't check the value of domain, it only checks the group, version, kind and related values which are provided in create api command. (kb create api). Domain is provided at the init level. Hence this check is necessary to know a user do not provide domain and group both as empty.

return fmt.Errorf("can not have group and domain both empty")
}

// TODO: re-evaluate whether y/n input still makes sense. We should probably always
// scaffold the resource and controller.
reader := bufio.NewReader(os.Stdin)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,11 @@ type Group struct {
func (f *Group) SetTemplateDefaults() error {
if f.Path == "" {
if f.MultiGroup {
f.Path = filepath.Join("apis", "%[group]", "%[version]", "groupversion_info.go")
if f.Resource.Group != "" {
f.Path = filepath.Join("apis", "%[group]", "%[version]", "groupversion_info.go")
} else {
f.Path = filepath.Join("apis", "%[version]", "groupversion_info.go")
}
} else {
f.Path = filepath.Join("api", "%[version]", "groupversion_info.go")
}
prafull01 marked this conversation as resolved.
Show resolved Hide resolved
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@ type Types struct {
func (f *Types) SetTemplateDefaults() error {
if f.Path == "" {
if f.MultiGroup {
f.Path = filepath.Join("apis", "%[group]", "%[version]", "%[kind]_types.go")
if f.Resource.Group != "" {
f.Path = filepath.Join("apis", "%[group]", "%[version]", "%[kind]_types.go")
} else {
f.Path = filepath.Join("apis", "%[version]", "%[kind]_types.go")
}
} else {
f.Path = filepath.Join("api", "%[version]", "%[kind]_types.go")
}
prafull01 marked this conversation as resolved.
Show resolved Hide resolved
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,11 @@ type Webhook struct { // nolint:maligned
func (f *Webhook) SetTemplateDefaults() error {
if f.Path == "" {
if f.MultiGroup {
f.Path = filepath.Join("apis", "%[group]", "%[version]", "%[kind]_webhook.go")
if f.Resource.Group != "" {
f.Path = filepath.Join("apis", "%[group]", "%[version]", "%[kind]_webhook.go")
} else {
f.Path = filepath.Join("apis", "%[version]", "%[kind]_webhook.go")
}
} else {
f.Path = filepath.Join("api", "%[version]", "%[kind]_webhook.go")
}
prafull01 marked this conversation as resolved.
Show resolved Hide resolved
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ type Controller struct {
// SetTemplateDefaults implements input.Template
func (f *Controller) SetTemplateDefaults() error {
if f.Path == "" {
if f.MultiGroup {
if f.MultiGroup && f.Resource.Group != "" {
f.Path = filepath.Join("controllers", "%[group]", "%[kind]_controller.go")
} else {
f.Path = filepath.Join("controllers", "%[kind]_controller.go")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ type SuiteTest struct {
// SetTemplateDefaults implements file.Template
func (f *SuiteTest) SetTemplateDefaults() error {
if f.Path == "" {
if f.MultiGroup {
if f.MultiGroup && f.Resource.Group != "" {
f.Path = filepath.Join("controllers", "%[group]", "suite_test.go")
} else {
f.Path = filepath.Join("controllers", "suite_test.go")
camilamacedo86 marked this conversation as resolved.
Show resolved Hide resolved
Expand All @@ -57,7 +57,7 @@ func (f *SuiteTest) SetTemplateDefaults() error {
// If is multigroup the path needs to be ../../ since it has
// the group dir.
f.CRDDirectoryRelativePath = `".."`
if f.MultiGroup {
if f.MultiGroup && f.Resource.Group != "" {
f.CRDDirectoryRelativePath = `"..", ".."`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

}

Expand Down
4 changes: 2 additions & 2 deletions pkg/plugin/v3/scaffolds/internal/templates/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ func (f *MainUpdater) GetCodeFragments() file.CodeFragmentsMap {
imports := make([]string, 0)
imports = append(imports, fmt.Sprintf(apiImportCodeFragment, f.Resource.ImportAlias, f.Resource.Package))
prafull01 marked this conversation as resolved.
Show resolved Hide resolved
if f.WireController {
if !f.MultiGroup {
if !f.MultiGroup || f.Resource.Group == "" {
imports = append(imports, fmt.Sprintf(controllerImportCodeFragment, f.Repo))
} else {
imports = append(imports, fmt.Sprintf(multiGroupControllerImportCodeFragment,
Expand All @@ -165,7 +165,7 @@ func (f *MainUpdater) GetCodeFragments() file.CodeFragmentsMap {
// Generate setup code fragments
setup := make([]string, 0)
if f.WireController {
if !f.MultiGroup {
if !f.MultiGroup || f.Resource.Group == "" {
setup = append(setup, fmt.Sprintf(reconcilerSetupCodeFragment,
f.Resource.Kind, f.Resource.Kind, f.Resource.Kind))
} else {
Expand Down
2 changes: 2 additions & 0 deletions testdata/project-v3-multigroup/PROJECT
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,6 @@ resources:
- group: foo.policy
kind: HealthCheckPolicy
version: v1
- kind: Lakers
version: v1
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

version: 3-alpha
Loading