Skip to content

Commit dd84707

Browse files
authored
Merge pull request #5194 from camilamacedo86/new-feature-pin-version
✨ (go/v4): Allow informing Go module for external APIs when pinning a downgraded version is required
2 parents ab6da88 + fa4f2c8 commit dd84707

File tree

14 files changed

+318
-101
lines changed

14 files changed

+318
-101
lines changed

docs/book/src/reference/project-config.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,14 @@ resources:
5959
kind: Busybox
6060
path: sigs.k8s.io/kubebuilder/testdata/project-v4-with-deploy-image/api/v1alpha1
6161
version: v1alpha1
62+
- controller: true
63+
domain: io
64+
external: true
65+
group: cert-manager
66+
kind: Certificate
67+
path: github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1
68+
module: github.com/cert-manager/cert-manager@v1.18.2
69+
version: v1
6270
version: "3"
6371
```
6472
## Why do we need to store the plugins and data used?
@@ -127,6 +135,14 @@ resources:
127135
kind: Busybox
128136
path: sigs.k8s.io/kubebuilder/testdata/project-v4-with-deploy-image/api/v1alpha1
129137
version: v1alpha1
138+
- controller: true
139+
domain: io
140+
external: true
141+
group: cert-manager
142+
kind: Certificate
143+
path: github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1
144+
module: github.com/cert-manager/cert-manager@v1.18.2
145+
version: v1
130146
version: "3"
131147
```
132148

@@ -152,6 +168,7 @@ Now let's check its layout fields definition:
152168
| `resources.path` | The import path for the API resource. It will be `<repo>/api/<kind>` unless the API added to the project is an external or core-type. For the core-types scenarios, the paths used are mapped [here][core-types]. Or either the path informed by the flag `--external-api-path` |
153169
| `resources.core` | It is `true` when the group used is from Kubernetes API and the API resource is not defined on the project. |
154170
| `resources.external` | It is `true` when the flag `--external-api-path` was used to generated the scaffold for an [External Type][external-type]. |
171+
| `resources.module` | **(Optional)** The Go module path for external API dependencies, optionally including a version (e.g., `github.com/cert-manager/cert-manager@v1.18.2` or just `github.com/cert-manager/cert-manager`). Only used when `external` is `true`. Provided via the `--external-api-module` flag to explicitly pin a specific version in `go.mod` or to specify the module when it cannot be automatically determined from `--external-api-path`. If not provided, `go mod tidy` will resolve the dependency automatically. |
155172
| `resources.webhooks` | Store the webhooks data when the sub-command `create webhook` is used. |
156173
| `resources.webhooks.spoke` | Store the API version that will act as the Spoke with the designated Hub version for conversion webhooks. |
157174
| `resources.webhooks.webhookVersion` | The Kubernetes API version (`apiVersion`) used to scaffold the webhook resource. |

docs/book/src/reference/using_an_external_resource.md

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,25 @@ For example, if you're managing Certificates from Cert Manager:
3131
kubebuilder create api --group certmanager --version v1 --kind Certificate --controller=true --resource=false --external-api-path=github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1 --external-api-domain=io
3232
```
3333

34+
<aside class="note">
35+
<h1>Pinning External API Versions</h1>
36+
37+
You can pin a specific version of the external API dependency using the `--external-api-module` flag:
38+
39+
```shell
40+
kubebuilder create api --group certmanager --version v1 --kind Certificate \
41+
--controller=true --resource=false \
42+
--external-api-path=github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1 \
43+
--external-api-domain=io \
44+
--external-api-module=github.com/cert-manager/cert-manager@v1.18.2
45+
```
46+
47+
The flag accepts the module path with optional version (e.g., `github.com/cert-manager/cert-manager@v1.18.2`).
48+
The module is stored in the PROJECT file and added to `go.mod` using `go get`,
49+
which cleanly adds it as a direct dependency without polluting go.mod with unnecessary indirect dependencies.
50+
51+
</aside>
52+
3453
See the RBAC [markers][markers-rbac] generated for this:
3554

3655
```go
@@ -75,10 +94,23 @@ definitions since the type is defined in an external project.
7594

7695
### Creating a Webhook to Manage an External Type
7796

78-
Following an example:
97+
You can create webhooks for external types by providing the external API path, domain, and optionally the module:
98+
99+
```shell
100+
kubebuilder create webhook --group certmanager --version v1 --kind Issuer \
101+
--defaulting --programmatic-validation \
102+
--external-api-path=github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1 \
103+
--external-api-domain=cert-manager.io
104+
```
105+
106+
You can also pin the version using the `--external-api-module` flag:
79107

80108
```shell
81-
kubebuilder create webhook --group certmanager --version v1 --kind Issuer --defaulting --programmatic-validation --external-api-path=github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1 --external-api-domain=cert-manager.io
109+
kubebuilder create webhook --group certmanager --version v1 --kind Issuer \
110+
--defaulting --programmatic-validation \
111+
--external-api-path=github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1 \
112+
--external-api-domain=cert-manager.io \
113+
--external-api-module=github.com/cert-manager/cert-manager@v1.18.2
82114
```
83115

84116
## Managing Core Types

pkg/cli/alpha/internal/generate.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -491,10 +491,14 @@ func createAPI(res resource.Resource) error {
491491
args := append([]string{"create", "api"}, getGVKFlags(res)...)
492492
args = append(args, getAPIResourceFlags(res)...)
493493

494-
// Add the external API path flag if the resource is external
494+
// Add the external API flags if the resource is external
495495
if res.IsExternal() {
496496
args = append(args, "--external-api-path", res.Path)
497497
args = append(args, "--external-api-domain", res.Domain)
498+
// Add module if specified
499+
if res.Module != "" {
500+
args = append(args, "--external-api-module", res.Module)
501+
}
498502
}
499503

500504
if err := util.RunCmd("kubebuilder create api", "kubebuilder", args...); err != nil {
@@ -547,6 +551,10 @@ func getWebhookResourceFlags(res resource.Resource) []string {
547551
if res.IsExternal() {
548552
args = append(args, "--external-api-path", res.Path)
549553
args = append(args, "--external-api-domain", res.Domain)
554+
// Add module if specified
555+
if res.Module != "" {
556+
args = append(args, "--external-api-module", res.Module)
557+
}
550558
}
551559
if res.HasValidationWebhook() {
552560
args = append(args, "--programmatic-validation")

pkg/cli/alpha/internal/generate_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,45 @@ var _ = Describe("generate: get-args-helpers", func() {
430430
Expect(flags).To(ContainElements("--external-api-path", "external/test", "--external-api-domain", "test",
431431
"--programmatic-validation", "--defaulting", "--conversion", "--spoke", "v2"))
432432
})
433+
434+
It("returns correct flags for external resources with module version", func() {
435+
res := resource.Resource{
436+
Path: "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1",
437+
Module: "github.com/cert-manager/cert-manager@v1.18.2",
438+
GVK: resource.GVK{Group: "cert-manager", Version: "v1", Kind: "Certificate", Domain: "io"},
439+
External: true,
440+
Webhooks: &resource.Webhooks{
441+
Defaulting: true,
442+
},
443+
}
444+
flags := getWebhookResourceFlags(res)
445+
Expect(flags).To(ContainElement("--external-api-path"))
446+
Expect(flags).To(ContainElement("github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1"))
447+
Expect(flags).To(ContainElement("--external-api-domain"))
448+
Expect(flags).To(ContainElement("io"))
449+
Expect(flags).To(ContainElement("--external-api-module"))
450+
Expect(flags).To(ContainElement("github.com/cert-manager/cert-manager@v1.18.2"))
451+
Expect(flags).To(ContainElement("--defaulting"))
452+
})
453+
454+
It("returns correct flags for external resources WITHOUT module version", func() {
455+
res := resource.Resource{
456+
Path: "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1",
457+
Module: "", // No module specified
458+
GVK: resource.GVK{Group: "cert-manager", Version: "v1", Kind: "Certificate", Domain: "io"},
459+
External: true,
460+
Webhooks: &resource.Webhooks{
461+
Defaulting: true,
462+
},
463+
}
464+
flags := getWebhookResourceFlags(res)
465+
Expect(flags).To(ContainElement("--external-api-path"))
466+
Expect(flags).To(ContainElement("github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1"))
467+
Expect(flags).To(ContainElement("--external-api-domain"))
468+
Expect(flags).To(ContainElement("io"))
469+
Expect(flags).NotTo(ContainElement("--external-api-module"))
470+
Expect(flags).To(ContainElement("--defaulting"))
471+
})
433472
})
434473
})
435474

@@ -485,6 +524,34 @@ var _ = Describe("generate: create-helpers", func() {
485524
// Run createAPI and verify no errors
486525
Expect(createAPI(res)).To(Succeed())
487526
})
527+
528+
It("runs kubebuilder create api successfully with module version", func() {
529+
res := resource.Resource{
530+
GVK: resource.GVK{Group: "cert-manager", Version: "v1", Kind: "Certificate", Domain: "io"},
531+
Plural: "certificates",
532+
API: nil, // External resources typically don't scaffold API
533+
Controller: true,
534+
External: true,
535+
Path: "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1",
536+
Module: "github.com/cert-manager/cert-manager@v1.18.2",
537+
}
538+
// Run createAPI and verify no errors
539+
Expect(createAPI(res)).To(Succeed())
540+
})
541+
542+
It("runs kubebuilder create api successfully WITHOUT module version", func() {
543+
res := resource.Resource{
544+
GVK: resource.GVK{Group: "cert-manager", Version: "v1", Kind: "Certificate", Domain: "io"},
545+
Plural: "certificates",
546+
API: nil,
547+
Controller: true,
548+
External: true,
549+
Path: "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1",
550+
Module: "", // No module specified
551+
}
552+
// Run createAPI and verify no errors
553+
Expect(createAPI(res)).To(Succeed())
554+
})
488555
})
489556
})
490557

@@ -525,6 +592,36 @@ var _ = Describe("generate: create-helpers", func() {
525592
// Run createAPIWithDeployImage and verify no errors
526593
Expect(createAPIWithDeployImage(resourceData)).To(Succeed())
527594
})
595+
596+
It("validates deploy-image works with external APIs without release version", func() {
597+
// This test validates that deploy-image plugin can work with external APIs
598+
// even without pinned versions (backward compatibility)
599+
resourceData := deployimagev1alpha1.ResourceData{
600+
Group: "cert-manager",
601+
Domain: "io",
602+
Version: "v1",
603+
Kind: "Certificate",
604+
}
605+
resourceData.Options.Image = "busybox:1.36.1"
606+
resourceData.Options.RunAsUser = "1001"
607+
// Run createAPIWithDeployImage and verify no errors
608+
Expect(createAPIWithDeployImage(resourceData)).To(Succeed())
609+
})
610+
611+
It("validates deploy-image can be used alongside external APIs with release version", func() {
612+
// This test validates that when external APIs with release versions are used,
613+
// deploy-image plugin still works correctly
614+
// Note: The release field is stored in the Resource, not in DeployImage's ResourceData
615+
resourceData := deployimagev1alpha1.ResourceData{
616+
Group: "example.com",
617+
Version: "v1",
618+
Kind: "Memcached",
619+
}
620+
resourceData.Options.Image = "memcached:1.6.26"
621+
resourceData.Options.ContainerPort = "11211"
622+
// Run createAPIWithDeployImage and verify no errors
623+
Expect(createAPIWithDeployImage(resourceData)).To(Succeed())
624+
})
528625
})
529626
})
530627

pkg/model/resource/resource.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ type Resource struct {
4646
// External specifies if the resource is defined externally.
4747
External bool `json:"external,omitempty"`
4848

49+
// Module specifies the Go module path for external API dependencies.
50+
// Can optionally include @version to pin the dependency (e.g., "github.com/org/repo@v1.2.3").
51+
// This is only used when External is true.
52+
Module string `json:"module,omitempty"`
53+
4954
// Core specifies if the resource is from Kubernetes API.
5055
Core bool `json:"core,omitempty"`
5156
}

pkg/plugins/golang/options.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,14 @@ type Options struct {
5757
// ExternalAPIPath allows to inform a path for APIs not defined in the project
5858
ExternalAPIPath string
5959

60-
// ExternalAPIPath allows to inform the resource domain to build the Qualified Group
60+
// ExternalAPIDomain allows to inform the resource domain to build the Qualified Group
6161
// to generate the RBAC markers
6262
ExternalAPIDomain string
6363

64+
// ExternalAPIModule specifies the Go module path for the external API with optional version.
65+
// Example: github.com/cert-manager/cert-manager@v1.18.2
66+
ExternalAPIModule string
67+
6468
// Namespaced is true if the resource should be namespaced.
6569
Namespaced bool
6670

@@ -124,6 +128,14 @@ func (opts Options) UpdateResource(res *resource.Resource, c config.Config) {
124128

125129
if len(opts.ExternalAPIPath) > 0 {
126130
res.External = true
131+
res.Path = opts.ExternalAPIPath
132+
if len(opts.ExternalAPIDomain) > 0 {
133+
res.Domain = opts.ExternalAPIDomain
134+
}
135+
// Store module path if provided
136+
if len(opts.ExternalAPIModule) > 0 {
137+
res.Module = opts.ExternalAPIModule
138+
}
127139
}
128140

129141
// domain and path may need to be changed in case we are referring to a builtin core resource:

pkg/plugins/golang/v4/api.go

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,9 @@ func (p *createAPISubcommand) BindFlags(fs *pflag.FlagSet) {
116116
fs.StringVar(&p.options.ExternalAPIDomain, "external-api-domain", "",
117117
"Specify the domain name for the external API. This domain is used to generate accurate RBAC "+
118118
"markers and permissions for the external resources (e.g., cert-manager.io).")
119+
120+
fs.StringVar(&p.options.ExternalAPIModule, "external-api-module", "",
121+
"external API module with optional version (e.g., github.com/cert-manager/cert-manager@v1.18.2)")
119122
}
120123

121124
func (p *createAPISubcommand) InjectConfig(c config.Config) error {
@@ -138,13 +141,19 @@ func (p *createAPISubcommand) InjectResource(res *resource.Resource) error {
138141

139142
// Ensure that external API options cannot be used when creating an API in the project.
140143
if p.options.DoAPI {
141-
if len(p.options.ExternalAPIPath) != 0 || len(p.options.ExternalAPIDomain) != 0 {
142-
return errors.New("cannot use '--external-api-path' or '--external-api-domain' " +
144+
if len(p.options.ExternalAPIPath) != 0 || len(p.options.ExternalAPIDomain) != 0 ||
145+
len(p.options.ExternalAPIModule) != 0 {
146+
return errors.New("cannot use '--external-api-path', '--external-api-domain', or '--external-api-module' " +
143147
"when creating an API in the project with '--resource=true'. " +
144148
"Use '--resource=false' when referencing an external API")
145149
}
146150
}
147151

152+
// Validate that --external-api-module requires --external-api-path
153+
if len(p.options.ExternalAPIModule) != 0 && len(p.options.ExternalAPIPath) == 0 {
154+
return errors.New("'--external-api-module' requires '--external-api-path' to be specified")
155+
}
156+
148157
p.options.UpdateResource(p.resource, p.config)
149158

150159
if err := p.resource.Validate(); err != nil {
@@ -188,6 +197,16 @@ func (p *createAPISubcommand) Scaffold(fs machinery.Filesystem) error {
188197
}
189198

190199
func (p *createAPISubcommand) PostScaffold() error {
200+
// If external API with module specified, add it using go get
201+
if p.resource.IsExternal() && p.resource.Module != "" {
202+
log.Info("Adding external API dependency", "module", p.resource.Module)
203+
// Use go get to add the dependency cleanly as a direct requirement
204+
err := util.RunCmd("Add external API dependency", "go", "get", p.resource.Module)
205+
if err != nil {
206+
return fmt.Errorf("error adding external API dependency: %w", err)
207+
}
208+
}
209+
191210
err := util.RunCmd("Update dependencies", "go", "mod", "tidy")
192211
if err != nil {
193212
return fmt.Errorf("error updating go dependencies: %w", err)

pkg/plugins/golang/v4/webhook.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package v4
1919
import (
2020
"errors"
2121
"fmt"
22+
log "log/slog"
2223
"strings"
2324

2425
"github.com/spf13/pflag"
@@ -115,13 +116,16 @@ func (p *createWebhookSubcommand) BindFlags(fs *pflag.FlagSet) {
115116
"This option will be removed in future versions.")
116117

117118
fs.StringVar(&p.options.ExternalAPIPath, "external-api-path", "",
118-
"Specify the Go package import path for the external API. This is used to scaffold controllers for resources "+
119+
"Specify the Go package import path for the external API. This is used to scaffold webhooks for resources "+
119120
"defined outside this project (e.g., github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1).")
120121

121122
fs.StringVar(&p.options.ExternalAPIDomain, "external-api-domain", "",
122123
"Specify the domain name for the external API. This domain is used to generate accurate RBAC "+
123124
"markers and permissions for the external resources (e.g., cert-manager.io).")
124125

126+
fs.StringVar(&p.options.ExternalAPIModule, "external-api-module", "",
127+
"external API module with optional version (e.g., github.com/cert-manager/cert-manager@v1.18.2)")
128+
125129
fs.BoolVar(&p.force, "force", false,
126130
"attempt to create resource even if it already exists")
127131
}
@@ -154,6 +158,11 @@ func (p *createWebhookSubcommand) InjectResource(res *resource.Resource) error {
154158
return fmt.Errorf("--validation-path can only be used with --programmatic-validation")
155159
}
156160

161+
// Validate that --external-api-module requires --external-api-path
162+
if len(p.options.ExternalAPIModule) != 0 && len(p.options.ExternalAPIPath) == 0 {
163+
return errors.New("'--external-api-module' requires '--external-api-path' to be specified")
164+
}
165+
157166
p.options.UpdateResource(p.resource, p.config)
158167

159168
if err := p.resource.Validate(); err != nil {
@@ -193,6 +202,16 @@ func (p *createWebhookSubcommand) Scaffold(fs machinery.Filesystem) error {
193202
}
194203

195204
func (p *createWebhookSubcommand) PostScaffold() error {
205+
// If external API with module specified, add it using go get
206+
if p.resource.IsExternal() && p.resource.Module != "" {
207+
log.Info("Adding external API dependency", "module", p.resource.Module)
208+
// Use go get to add the dependency cleanly as a direct requirement
209+
err := pluginutil.RunCmd("Add external API dependency", "go", "get", p.resource.Module)
210+
if err != nil {
211+
return fmt.Errorf("error adding external API dependency: %w", err)
212+
}
213+
}
214+
196215
err := pluginutil.RunCmd("Update dependencies", "go", "mod", "tidy")
197216
if err != nil {
198217
return fmt.Errorf("error updating go dependencies: %w", err)

test/e2e/alphagenerate/generate_v4_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,8 @@ func validateV4ProjectFile(kbc *utils.TestContext, projectFile string) {
170170
Expect(certmanagerResource.Webhooks).To(BeNil(), "Certificate API should not have webhooks")
171171
Expect(certmanagerResource.Path).To(Equal("github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1"),
172172
"Certificate API should have expected path")
173+
Expect(certmanagerResource.Module).To(Equal("github.com/cert-manager/cert-manager@v1.18.2"),
174+
"Certificate API should have module with version v1.18.2 stored in PROJECT file")
173175

174176
By("validating the External API with kind Issuer from certManager")
175177
issuerGVK := resource.GVK{
@@ -187,6 +189,8 @@ func validateV4ProjectFile(kbc *utils.TestContext, projectFile string) {
187189
Expect(issuerResource.API).To(BeNil(), "Issuer API should not have API scaffold")
188190
Expect(issuerResource.Path).To(Equal("github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1"),
189191
"Issuer API should have expected path")
192+
Expect(issuerResource.Module).To(Equal("github.com/cert-manager/cert-manager@v1.18.2"),
193+
"Issuer API should have module with version v1.18.2 stored in PROJECT file")
190194

191195
By("validating the Webhook for Issuer API")
192196
Expect(admiralResource.Webhooks.Defaulting).To(BeTrue(), "Issuer API should have a defaulting webhook")

0 commit comments

Comments
 (0)