Skip to content

Commit ee64539

Browse files
committed
fix(go/v4): resolve create webhook target when several share a GVK
create webhook matched a recorded resource by Group, Version and Kind and took the first hit. When a project records more than one external resource that shares a GVK under different domains, it silently bound the webhook to whichever entry the PROJECT file listed first, and passing --external-api-domain to select another one failed outright. Collect every Group/Version/Kind match instead of taking the first. With a single match, behave as before. With several, use --external-api-domain to select one, and return an error when it is missing or matches none so the command never guesses and leaves the PROJECT file unchanged.
1 parent 1893411 commit ee64539

2 files changed

Lines changed: 221 additions & 14 deletions

File tree

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

Lines changed: 67 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -241,29 +241,82 @@ func (p *createWebhookSubcommand) PostScaffold() error {
241241
return nil
242242
}
243243

244-
// updateResourceFromConfig copies existing resource configuration from PROJECT file.
244+
// updateResourceFromConfig fills res with the configuration recorded for its
245+
// Group/Version/Kind in the PROJECT file: Domain, Path, Plural, External, Core and Module.
246+
//
247+
// The lookup is by Group/Version/Kind rather than the full GVK because res still carries the
248+
// 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.
245252
func (p *createWebhookSubcommand) updateResourceFromConfig(res *resource.Resource) error {
246-
// Match by Group, Version, and Kind because external APIs may have
247-
// a different domain than the project domain.
248253
resources, err := p.config.GetResources()
249254
if err != nil {
250255
return fmt.Errorf("failed to load resources from project configuration: %w", err)
251256
}
252257

253-
for _, existingRes := range resources {
254-
if existingRes.Group == res.Group &&
255-
existingRes.Version == res.Version &&
256-
existingRes.Kind == res.Kind {
257-
p.resource.Domain = existingRes.Domain
258-
p.resource.Path = existingRes.Path
259-
p.resource.Plural = existingRes.Plural
260-
p.resource.External = existingRes.External
261-
p.resource.Core = existingRes.Core
262-
p.resource.Module = existingRes.Module
263-
break
258+
// Collect every recorded resource sharing this Group/Version/Kind.
259+
var matches []resource.Resource
260+
for _, r := range resources {
261+
if r.Group == res.Group && r.Version == res.Version && r.Kind == res.Kind {
262+
matches = append(matches, r)
263+
}
264+
}
265+
266+
var existingRes resource.Resource
267+
switch len(matches) {
268+
case 0:
269+
return nil // nothing recorded for this GVK; keep res as built from the flags
270+
case 1:
271+
existingRes = matches[0]
272+
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+
}
284+
} 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+
}
292+
}
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+
)
304+
}
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+
)
264310
}
265311
}
266312

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
319+
267320
return nil
268321
}
269322

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

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,160 @@ var _ = Describe("createWebhookSubcommand", func() {
223223
Expect(err.Error()).To(ContainSubstring("or pass --external-api-path for an external type"))
224224
})
225225

226+
Context("when several external resources share the same Group/Version/Kind", func() {
227+
const (
228+
pathIO = "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1"
229+
pathK8sIO = "github.com/cert-manager/cert-manager/pkg/apis/acme/v1"
230+
domainIO = "cert-manager.io"
231+
domainK8s = "cert-manager.k8s.io"
232+
)
233+
234+
// storeExternal records an external resource sharing crewGroup/v1/captainKind but under
235+
// the given domain. Recorded resources are unique by full GVK, so distinct domains produce
236+
// distinct entries that collide only on Group/Version/Kind.
237+
storeExternal := func(domain, path string) {
238+
Expect(cfg.AddResource(resource.Resource{
239+
GVK: resource.GVK{
240+
Group: crewGroup,
241+
Domain: domain,
242+
Version: "v1",
243+
Kind: captainKind,
244+
},
245+
External: true,
246+
Path: path,
247+
Webhooks: &resource.Webhooks{},
248+
})).To(Succeed())
249+
}
250+
251+
// storedByDomain re-reads the recorded resources keyed by domain, to assert entries are
252+
// left untouched.
253+
storedByDomain := func() map[string]resource.Resource {
254+
stored, err := cfg.GetResources()
255+
Expect(err).NotTo(HaveOccurred())
256+
byDomain := make(map[string]resource.Resource, len(stored))
257+
for _, s := range stored {
258+
byDomain[s.Domain] = s
259+
}
260+
return byDomain
261+
}
262+
263+
It("should refuse and leave the PROJECT untouched when no domain is given", func() {
264+
Expect(subCmd.InjectConfig(cfg)).To(Succeed())
265+
storeExternal(domainIO, pathIO)
266+
storeExternal(domainK8s, pathK8sIO)
267+
subCmd.options.DoDefaulting = true
268+
269+
err := subCmd.InjectResource(res)
270+
271+
Expect(err).To(HaveOccurred())
272+
Expect(err.Error()).To(ContainSubstring("match more than one resource"))
273+
Expect(err.Error()).To(ContainSubstring("pass --external-api-domain"))
274+
Expect(err.Error()).To(ContainSubstring(domainIO))
275+
Expect(err.Error()).To(ContainSubstring(domainK8s))
276+
277+
// Both entries keep their path and domain.
278+
byDomain := storedByDomain()
279+
Expect(byDomain).To(HaveLen(2))
280+
Expect(byDomain[domainIO].Path).To(Equal(pathIO))
281+
Expect(byDomain[domainK8s].Path).To(Equal(pathK8sIO))
282+
})
283+
284+
It("should refuse regardless of the order the resources are recorded", func() {
285+
Expect(subCmd.InjectConfig(cfg)).To(Succeed())
286+
// Reverse order from the previous test: nothing may depend on file order.
287+
storeExternal(domainK8s, pathK8sIO)
288+
storeExternal(domainIO, pathIO)
289+
subCmd.options.DoDefaulting = true
290+
291+
err := subCmd.InjectResource(res)
292+
293+
Expect(err).To(HaveOccurred())
294+
Expect(err.Error()).To(ContainSubstring("match more than one resource"))
295+
})
296+
297+
It("should work on the resource named by --external-api-domain and leave the other alone", func() {
298+
Expect(subCmd.InjectConfig(cfg)).To(Succeed())
299+
storeExternal(domainIO, pathIO)
300+
storeExternal(domainK8s, pathK8sIO)
301+
subCmd.options.DoDefaulting = true
302+
subCmd.options.ExternalAPIDomain = domainK8s
303+
304+
err := subCmd.InjectResource(res)
305+
306+
Expect(err).NotTo(HaveOccurred())
307+
Expect(res.External).To(BeTrue())
308+
Expect(res.Domain).To(Equal(domainK8s))
309+
Expect(res.Path).To(Equal(pathK8sIO))
310+
311+
// The unnamed entry is untouched.
312+
Expect(storedByDomain()[domainIO].Path).To(Equal(pathIO))
313+
})
314+
315+
It("should refuse when the given domain matches none of them", func() {
316+
Expect(subCmd.InjectConfig(cfg)).To(Succeed())
317+
storeExternal(domainIO, pathIO)
318+
storeExternal(domainK8s, pathK8sIO)
319+
subCmd.options.DoDefaulting = true
320+
subCmd.options.ExternalAPIDomain = "does-not-exist.io"
321+
322+
err := subCmd.InjectResource(res)
323+
324+
Expect(err).To(HaveOccurred())
325+
Expect(err.Error()).To(ContainSubstring("no resource matches --external-api-domain"))
326+
Expect(err.Error()).To(ContainSubstring("does-not-exist.io"))
327+
Expect(err.Error()).To(ContainSubstring(domainIO))
328+
Expect(err.Error()).To(ContainSubstring(domainK8s))
329+
})
330+
331+
It("should refuse without a domain even when one entry has an empty domain", func() {
332+
Expect(subCmd.InjectConfig(cfg)).To(Succeed())
333+
// An empty flag must not silently select the empty-domain entry.
334+
storeExternal("", pathK8sIO)
335+
storeExternal(domainIO, pathIO)
336+
subCmd.options.DoDefaulting = true
337+
338+
err := subCmd.InjectResource(res)
339+
340+
Expect(err).To(HaveOccurred())
341+
Expect(err.Error()).To(ContainSubstring("match more than one resource"))
342+
})
343+
344+
It("should prefer the non-external entry when no domain is given", func() {
345+
Expect(subCmd.InjectConfig(cfg)).To(Succeed())
346+
// A project (non-external) entry shares the GVK with an external one.
347+
Expect(cfg.AddResource(resource.Resource{
348+
GVK: resource.GVK{Group: crewGroup, Domain: testIO, Version: "v1", Kind: captainKind},
349+
Path: "github.com/example/test/api/v1",
350+
Webhooks: &resource.Webhooks{},
351+
})).To(Succeed())
352+
storeExternal(domainK8s, pathK8sIO)
353+
subCmd.options.DoDefaulting = true
354+
355+
err := subCmd.InjectResource(res)
356+
357+
// Resolves to the non-external entry, not the external one, without a domain.
358+
Expect(err).NotTo(HaveOccurred())
359+
Expect(res.External).To(BeFalse())
360+
})
361+
})
362+
363+
It("should resolve a core type without --external-api-domain", func() {
364+
Expect(subCmd.InjectConfig(cfg)).To(Succeed())
365+
// A single recorded core type must resolve without --external-api-domain.
366+
Expect(cfg.AddResource(resource.Resource{
367+
GVK: res.GVK,
368+
Core: true,
369+
Path: "k8s.io/api/core/v1",
370+
Webhooks: &resource.Webhooks{},
371+
})).To(Succeed())
372+
subCmd.options.DoDefaulting = true
373+
374+
err := subCmd.InjectResource(res)
375+
376+
Expect(err).NotTo(HaveOccurred())
377+
Expect(res.Core).To(BeTrue())
378+
})
379+
226380
Context("isValidVersion", func() {
227381
BeforeEach(func() {
228382
res = &resource.Resource{

0 commit comments

Comments
 (0)