Skip to content

Commit ac3800f

Browse files
committed
fix(go/v4): resolve core/project collisions and single-match domain in create webhook
Address review on the webhook GVK resolution: - When no --external-api-domain is given and several resources share the Group/Version/Kind, resolve to the non-external (core/project) entry instead of the first match, preferring the project when a core and project entry collide. The command only refuses when every match is external, so core and project types never hit the ambiguity error. - Refuse a command whose --external-api-domain matches no recorded resource unless an --external-api-path also defines a new one, consistently for a single or multiple matches. - Name the recorded domains in the ambiguity error and distinguish a missing domain from one that matched nothing. - Update the --external-api-domain flag help to mention resource selection. Resolution logic is a single length-based switch with two pure helpers (selectByDomain, selectNonExternal); tests cover every branch through the create webhook command with a decision table documenting the cases.
1 parent ee64539 commit ac3800f

2 files changed

Lines changed: 232 additions & 52 deletions

File tree

‎pkg/plugins/golang/v4/webhook.go‎

Lines changed: 94 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,9 @@ func (p *createWebhookSubcommand) BindFlags(fs *pflag.FlagSet) {
115115
"Used to scaffold webhooks for resources defined outside this project")
116116

117117
fs.StringVar(&p.options.ExternalAPIDomain, "external-api-domain", "",
118-
"Domain name for the external API (e.g., cert-manager.io). "+
119-
"Used to generate accurate RBAC markers and permissions for the external resources")
118+
"Domain for the external API (e.g., cert-manager.io). Selects the recorded resource when "+
119+
"several share a group, version, and kind, and is used to generate accurate RBAC markers "+
120+
"and permissions for the external resources")
120121

121122
fs.StringVar(&p.options.ExternalAPIModule, "external-api-module", "",
122123
"External API module with optional version (e.g., github.com/cert-manager/cert-manager@v1.18.2)")
@@ -246,78 +247,120 @@ func (p *createWebhookSubcommand) PostScaffold() error {
246247
//
247248
// The lookup is by Group/Version/Kind rather than the full GVK because res still carries the
248249
// project domain, while an external resource keeps its own. Only external APIs can register the
249-
// same Group/Version/Kind under different domains, so when several match: --external-api-domain
250-
// selects the intended one; without it the non-external (core/project) entry is used; and when
251-
// every match is external the request is ambiguous and refused.
250+
// same Group/Version/Kind under different domains, so when several match --external-api-domain
251+
// selects the intended one; without it resolution falls to the non-external (core/project) entry.
252252
func (p *createWebhookSubcommand) updateResourceFromConfig(res *resource.Resource) error {
253253
resources, err := p.config.GetResources()
254254
if err != nil {
255255
return fmt.Errorf("failed to load resources from project configuration: %w", err)
256256
}
257257

258258
// Collect every recorded resource sharing this Group/Version/Kind.
259-
var matches []resource.Resource
259+
var candidates []resource.Resource
260260
for _, r := range resources {
261261
if r.Group == res.Group && r.Version == res.Version && r.Kind == res.Kind {
262-
matches = append(matches, r)
262+
candidates = append(candidates, r)
263263
}
264264
}
265265

266-
var existingRes resource.Resource
267-
switch len(matches) {
266+
domain := p.options.ExternalAPIDomain
267+
var selected *resource.Resource
268+
switch len(candidates) {
268269
case 0:
269270
return nil // nothing recorded for this GVK; keep res as built from the flags
270271
case 1:
271-
existingRes = matches[0]
272+
if domain == "" {
273+
selected = &candidates[0] // recover the single record
274+
} else {
275+
selected, err = selectByDomain(candidates, domain, p.options.ExternalAPIPath)
276+
}
272277
default:
273-
// Several entries share this GVK — only external APIs can, under different domains.
274-
domain := p.options.ExternalAPIDomain
275-
found := false
276-
if domain != "" {
277-
// A domain was named: use the entry that carries it.
278-
for _, m := range matches {
279-
if m.Domain == domain {
280-
existingRes, found = m, true
281-
break
282-
}
283-
}
278+
if domain == "" {
279+
selected, err = selectNonExternal(candidates, res.Domain)
284280
} else {
285-
// No domain named: use the non-external (core/project) entry, if any.
286-
for _, m := range matches {
287-
if !m.External {
288-
existingRes, found = m, true
289-
break
290-
}
291-
}
281+
selected, err = selectByDomain(candidates, domain, p.options.ExternalAPIPath)
292282
}
293-
if !found {
294-
domains := make([]string, len(matches))
295-
for i, m := range matches {
296-
domains[i] = m.Domain
297-
}
298-
if domain != "" {
299-
return fmt.Errorf(
300-
"no resource matches --external-api-domain %q for group %q, version %q and kind %q "+
301-
"(recorded domains: %s)",
302-
domain, res.Group, res.Version, res.Kind, strings.Join(domains, ", "),
303-
)
283+
}
284+
if err != nil {
285+
return err
286+
}
287+
if selected == nil {
288+
return nil // the flags describe a new resource
289+
}
290+
291+
res.Domain = selected.Domain
292+
res.Path = selected.Path
293+
res.Plural = selected.Plural
294+
res.External = selected.External
295+
res.Core = selected.Core
296+
res.Module = selected.Module
297+
298+
return nil
299+
}
300+
301+
// selectByDomain resolves the candidate carrying the given --external-api-domain, for any number
302+
// of candidates. When none carries it, a non-empty external path means the flags describe a new
303+
// resource (nil, nil); otherwise the request is refused.
304+
func selectByDomain(candidates []resource.Resource, domain, externalPath string) (*resource.Resource, error) {
305+
for i := range candidates {
306+
if candidates[i].Domain == domain {
307+
return &candidates[i], nil
308+
}
309+
}
310+
if externalPath != "" {
311+
return nil, nil
312+
}
313+
return nil, resolutionError(candidates, domain)
314+
}
315+
316+
// selectNonExternal resolves several same-GVK candidates when no domain is given: the target is
317+
// the non-external (core/project) entry. When a core and project entry collide it keeps the
318+
// project (its domain equals projectDomain); when every candidate is external it refuses.
319+
func selectNonExternal(candidates []resource.Resource, projectDomain string) (*resource.Resource, error) {
320+
var nonExternal []resource.Resource
321+
for _, c := range candidates {
322+
if !c.External {
323+
nonExternal = append(nonExternal, c)
324+
}
325+
}
326+
327+
switch len(nonExternal) {
328+
case 0:
329+
return nil, resolutionError(candidates, "")
330+
case 1:
331+
return &nonExternal[0], nil
332+
default:
333+
// A core and a project resource share the GVK; keep the project one.
334+
for i := range nonExternal {
335+
if nonExternal[i].Domain == projectDomain {
336+
return &nonExternal[i], nil
304337
}
305-
return fmt.Errorf(
306-
"group %q, version %q and kind %q match more than one resource (domains: %s): "+
307-
"pass --external-api-domain to choose the one to work on",
308-
res.Group, res.Version, res.Kind, strings.Join(domains, ", "),
309-
)
310338
}
339+
return nil, resolutionError(candidates, "")
311340
}
341+
}
312342

313-
res.Domain = existingRes.Domain
314-
res.Path = existingRes.Path
315-
res.Plural = existingRes.Plural
316-
res.External = existingRes.External
317-
res.Core = existingRes.Core
318-
res.Module = existingRes.Module
343+
// resolutionError reports why the candidates could not be resolved to one, naming their domains.
344+
// With a named domain it reports that none matched; without one it asks for --external-api-domain.
345+
func resolutionError(candidates []resource.Resource, domain string) error {
346+
g, v, k := candidates[0].Group, candidates[0].Version, candidates[0].Kind
347+
domains := make([]string, len(candidates))
348+
for i, c := range candidates {
349+
domains[i] = c.Domain
350+
}
319351

320-
return nil
352+
if domain != "" {
353+
return fmt.Errorf(
354+
"no resource matches --external-api-domain %q for group %q, version %q and kind %q "+
355+
"(recorded domains: %s)",
356+
domain, g, v, k, strings.Join(domains, ", "),
357+
)
358+
}
359+
return fmt.Errorf(
360+
"group %q, version %q and kind %q match more than one resource (domains: %s): "+
361+
"pass --external-api-domain to choose the one to work on",
362+
g, v, k, strings.Join(domains, ", "),
363+
)
321364
}
322365

323366
// Helper function to validate spoke versions

‎pkg/plugins/golang/v4/webhook_test.go‎

Lines changed: 138 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ import (
2727
goPlugin "sigs.k8s.io/kubebuilder/v4/pkg/plugins/golang"
2828
)
2929

30+
// coreAPIPath is the Go import path recorded for a core (Kubernetes built-in) resource in tests.
31+
const coreAPIPath = "k8s.io/api/core/v1"
32+
3033
var _ = Describe("createWebhookSubcommand", func() {
3134
var (
3235
subCmd *createWebhookSubcommand
@@ -223,6 +226,23 @@ var _ = Describe("createWebhookSubcommand", func() {
223226
Expect(err.Error()).To(ContainSubstring("or pass --external-api-path for an external type"))
224227
})
225228

229+
// updateResourceFromConfig resolves which recorded resource a create-webhook command refers to.
230+
// The specs below cover the full decision, by number of recorded resources sharing the
231+
// Group/Version/Kind and the flags given:
232+
//
233+
// # | matches | --external-api-domain | matches a record? | --external-api-path | outcome
234+
// ---+---------+-----------------------+-------------------+---------------------+-------------------------
235+
// 1 | 0 | any | - | any | keep res from the flags
236+
// 2 | 1 | unset | - | - | use the record
237+
// 3 | 1 | set | yes | - | use the record
238+
// 4 | 1 | set | no | set | create a new resource
239+
// 5 | 1 | set | no | unset | error (domain not found)
240+
// 6 | >=2 | set | yes | - | use the record
241+
// 7 | >=2 | set | no | set | create a new resource
242+
// 8 | >=2 | set | no | unset | error (domain not found)
243+
// 9 | >=2 | unset | - | - | error (all external)
244+
// 10 | >=2 | unset | - (1 non-external)| - | use the non-external one
245+
// 11 | >=2 | unset | - (project+core) | - | use the project one
226246
Context("when several external resources share the same Group/Version/Kind", func() {
227247
const (
228248
pathIO = "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1"
@@ -358,6 +378,123 @@ var _ = Describe("createWebhookSubcommand", func() {
358378
Expect(err).NotTo(HaveOccurred())
359379
Expect(res.External).To(BeFalse())
360380
})
381+
382+
It("should create a new external variant when the domain is new and a path is given", func() {
383+
Expect(subCmd.InjectConfig(cfg)).To(Succeed())
384+
storeExternal(domainIO, pathIO)
385+
storeExternal(domainK8s, pathK8sIO)
386+
subCmd.options.DoDefaulting = true
387+
// A new domain plus a path fully describes a third external resource.
388+
subCmd.options.ExternalAPIDomain = "cert-manager.new.io"
389+
subCmd.options.ExternalAPIPath = "github.com/cert-manager/cert-manager/pkg/apis/new/v1"
390+
391+
err := subCmd.InjectResource(res)
392+
393+
Expect(err).NotTo(HaveOccurred())
394+
Expect(res.External).To(BeTrue())
395+
Expect(res.Domain).To(Equal("cert-manager.new.io"))
396+
})
397+
398+
It("should resolve to the core entry over an external one when no domain is given", func() {
399+
Expect(subCmd.InjectConfig(cfg)).To(Succeed())
400+
storeExternal(domainK8s, pathK8sIO)
401+
// A core entry shares the GVK with the external one.
402+
Expect(cfg.AddResource(resource.Resource{
403+
GVK: resource.GVK{Group: crewGroup, Domain: "k8s.io", Version: "v1", Kind: captainKind},
404+
Core: true,
405+
Path: coreAPIPath,
406+
Webhooks: &resource.Webhooks{},
407+
})).To(Succeed())
408+
subCmd.options.DoDefaulting = true
409+
410+
err := subCmd.InjectResource(res)
411+
412+
// The lone non-external (core) entry wins; the external one is left out.
413+
Expect(err).NotTo(HaveOccurred())
414+
Expect(res.Core).To(BeTrue())
415+
Expect(res.External).To(BeFalse())
416+
})
417+
418+
It("should keep the project entry when a project and core entry collide", func() {
419+
Expect(subCmd.InjectConfig(cfg)).To(Succeed())
420+
// A project entry (res's own domain) and a core entry share the GVK.
421+
Expect(cfg.AddResource(resource.Resource{
422+
GVK: resource.GVK{Group: crewGroup, Domain: testIO, Version: "v1", Kind: captainKind},
423+
Path: "github.com/example/test/api/v1",
424+
Webhooks: &resource.Webhooks{},
425+
})).To(Succeed())
426+
Expect(cfg.AddResource(resource.Resource{
427+
GVK: resource.GVK{Group: crewGroup, Domain: "k8s.io", Version: "v1", Kind: captainKind},
428+
Core: true,
429+
Path: coreAPIPath,
430+
Webhooks: &resource.Webhooks{},
431+
})).To(Succeed())
432+
subCmd.options.DoDefaulting = true
433+
434+
err := subCmd.InjectResource(res)
435+
436+
// The project entry (domain == res.Domain) is kept, not the core one.
437+
Expect(err).NotTo(HaveOccurred())
438+
Expect(res.Core).To(BeFalse())
439+
Expect(res.External).To(BeFalse())
440+
})
441+
})
442+
443+
Context("when a single external resource shares the Group/Version/Kind", func() {
444+
const (
445+
pathIO = "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1"
446+
domainIO = "cert-manager.io"
447+
)
448+
449+
storeExternal := func(domain, path string) {
450+
Expect(cfg.AddResource(resource.Resource{
451+
GVK: resource.GVK{Group: crewGroup, Domain: domain, Version: "v1", Kind: captainKind},
452+
External: true,
453+
Path: path,
454+
Webhooks: &resource.Webhooks{},
455+
})).To(Succeed())
456+
}
457+
458+
It("should use the recorded resource when --external-api-domain matches it", func() {
459+
Expect(subCmd.InjectConfig(cfg)).To(Succeed())
460+
storeExternal(domainIO, pathIO)
461+
subCmd.options.DoDefaulting = true
462+
subCmd.options.ExternalAPIDomain = domainIO
463+
464+
err := subCmd.InjectResource(res)
465+
466+
Expect(err).NotTo(HaveOccurred())
467+
Expect(res.External).To(BeTrue())
468+
Expect(res.Path).To(Equal(pathIO))
469+
Expect(res.Domain).To(Equal(domainIO))
470+
})
471+
472+
It("should refuse when --external-api-domain does not match and no path is given", func() {
473+
Expect(subCmd.InjectConfig(cfg)).To(Succeed())
474+
storeExternal(domainIO, pathIO)
475+
subCmd.options.DoDefaulting = true
476+
subCmd.options.ExternalAPIDomain = "cert-manager.k8s.io"
477+
478+
err := subCmd.InjectResource(res)
479+
480+
Expect(err).To(HaveOccurred())
481+
Expect(err.Error()).To(ContainSubstring("no resource matches --external-api-domain"))
482+
Expect(err.Error()).To(ContainSubstring(domainIO))
483+
})
484+
485+
It("should create a new external resource when the domain is new and a path is given", func() {
486+
Expect(subCmd.InjectConfig(cfg)).To(Succeed())
487+
storeExternal(domainIO, pathIO)
488+
subCmd.options.DoDefaulting = true
489+
subCmd.options.ExternalAPIDomain = "cert-manager.k8s.io"
490+
subCmd.options.ExternalAPIPath = "github.com/cert-manager/cert-manager/pkg/apis/acme/v1"
491+
492+
err := subCmd.InjectResource(res)
493+
494+
Expect(err).NotTo(HaveOccurred())
495+
Expect(res.External).To(BeTrue())
496+
Expect(res.Domain).To(Equal("cert-manager.k8s.io"))
497+
})
361498
})
362499

363500
It("should resolve a core type without --external-api-domain", func() {
@@ -366,7 +503,7 @@ var _ = Describe("createWebhookSubcommand", func() {
366503
Expect(cfg.AddResource(resource.Resource{
367504
GVK: res.GVK,
368505
Core: true,
369-
Path: "k8s.io/api/core/v1",
506+
Path: coreAPIPath,
370507
Webhooks: &resource.Webhooks{},
371508
})).To(Succeed())
372509
subCmd.options.DoDefaulting = true

0 commit comments

Comments
 (0)