Skip to content

Commit 2831857

Browse files
🐛 fix: resolve panic when multiple plugins define the same flag (e.g. edit with go/v4 + helm) (#5506)
fix: resolve panic when multiple plugins define the same flag (e.g. edit with go/v4 + helm) - Merge duplicate flags instead of binding twice; sync parsed value to all plugins in PreRunE. - Aggregate flag help as "For plugin (key): desc AND for plugin (key): desc". - Return a clear error when the same flag name is used with different types (e.g. bool vs string). Generated-by: Cursor/Claude
1 parent c0e3b37 commit 2831857

File tree

7 files changed

+301
-57
lines changed

7 files changed

+301
-57
lines changed

pkg/cli/cmd_helpers.go

Lines changed: 102 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@ import (
2020
"errors"
2121
"fmt"
2222
"os"
23+
"strings"
2324

2425
"github.com/spf13/cobra"
26+
"github.com/spf13/pflag"
2527

2628
"sigs.k8s.io/kubebuilder/v4/pkg/config"
2729
"sigs.k8s.io/kubebuilder/v4/pkg/config/store"
@@ -115,8 +117,9 @@ func collectSubcommands(
115117
}}
116118
}
117119

118-
// applySubcommandHooks runs the initialization hooks and configures the commands pre-run,
119-
// run, and post-run hooks with the appropriate execution hooks.
120+
// applySubcommandHooks runs the initialization hooks and wires pre-run, run, and post-run for the command.
121+
// Used by init, create api, create webhook, and edit. When several plugins define the same flag,
122+
// one flag is shown and its value is synced to all plugins after parse.
120123
func (c *CLI) applySubcommandHooks(
121124
cmd *cobra.Command,
122125
subcommands []keySubcommandTuple,
@@ -143,18 +146,23 @@ func (c *CLI) applySubcommandHooks(
143146
}
144147
}
145148

146-
options := initializationHooks(cmd, subcommands, c.metadata())
149+
result, err := initializationHooks(cmd, subcommands, c.metadata())
150+
if err != nil {
151+
cmdErr(cmd, err)
152+
return
153+
}
147154

148155
factory := executionHooksFactory{
149-
fs: c.fs,
150-
store: yamlstore.New(c.fs),
151-
subcommands: subcommands,
152-
errorMessage: errorMessage,
153-
projectVersion: c.projectVersion,
154-
pluginChain: pluginChain,
155-
cliVersion: c.cliVersion,
156+
fs: c.fs,
157+
store: yamlstore.New(c.fs),
158+
subcommands: subcommands,
159+
errorMessage: errorMessage,
160+
projectVersion: c.projectVersion,
161+
pluginChain: pluginChain,
162+
cliVersion: c.cliVersion,
163+
duplicateFlagValues: result.duplicateFlagValues,
156164
}
157-
cmd.PreRunE = factory.preRunEFunc(options, createConfig)
165+
cmd.PreRunE = factory.preRunEFunc(result.options, createConfig)
158166
cmd.RunE = factory.runEFunc()
159167
cmd.PostRunE = factory.postRunEFunc()
160168
}
@@ -166,12 +174,77 @@ func (c *CLI) appendPluginTable(cmd *cobra.Command, filter func(plugin.Plugin) b
166174
cmd.Long = fmt.Sprintf("%s\n%s:\n\n%s\n", cmd.Long, title, pluginTable)
167175
}
168176

169-
// initializationHooks executes update metadata and bind flags plugin hooks.
177+
// initHooksResult holds the result of initializationHooks: resource options and
178+
// duplicate-flag values to sync after parse.
179+
type initHooksResult struct {
180+
options *resourceOptions
181+
duplicateFlagValues map[string][]pflag.Value
182+
}
183+
184+
// mergeFlagSetInto merges flags from src into dest using AddFlagSet. If a flag name already exists,
185+
// the flag is not added again; its Value is stored in duplicateValues for later sync and the existing
186+
// Usage is extended. Returns an error if the same flag name is used with a different value type.
187+
func mergeFlagSetInto(
188+
dest *pflag.FlagSet,
189+
src *pflag.FlagSet,
190+
duplicateValues map[string][]pflag.Value,
191+
pluginKey string,
192+
firstPluginByFlag map[string]string,
193+
) error {
194+
destNames := make(map[string]struct{})
195+
dest.VisitAll(func(f *pflag.Flag) {
196+
destNames[f.Name] = struct{}{}
197+
})
198+
dest.AddFlagSet(src)
199+
200+
var err error
201+
src.VisitAll(func(flag *pflag.Flag) {
202+
if err != nil {
203+
return
204+
}
205+
existing := dest.Lookup(flag.Name)
206+
if _, wasInDest := destNames[flag.Name]; !wasInDest {
207+
firstPluginByFlag[flag.Name] = pluginKey
208+
existing.Usage = "For plugin (" + pluginKey + "): " + strings.TrimSpace(flag.Usage)
209+
return
210+
}
211+
if existing.Value.Type() != flag.Value.Type() {
212+
firstKey := firstPluginByFlag[flag.Name]
213+
err = fmt.Errorf(
214+
"plugins %q and %q use the same flag name %q but expect different value types: one %s, other %s",
215+
firstKey, pluginKey, flag.Name, existing.Value.Type(), flag.Value.Type(),
216+
)
217+
return
218+
}
219+
duplicateValues[flag.Name] = append(duplicateValues[flag.Name], flag.Value)
220+
existing.Usage += " AND for plugin (" + pluginKey + "): " + strings.TrimSpace(flag.Usage)
221+
})
222+
return err
223+
}
224+
225+
// syncDuplicateFlags copies the parsed value of each flag to all duplicate Values from merge.
226+
// Call after the command has parsed flags (e.g. at the start of PreRunE).
227+
func syncDuplicateFlags(flags *pflag.FlagSet, duplicateValues map[string][]pflag.Value) {
228+
for name, values := range duplicateValues {
229+
parsed := flags.Lookup(name)
230+
if parsed == nil {
231+
continue
232+
}
233+
srcVal := parsed.Value.String()
234+
for _, v := range values {
235+
_ = v.Set(srcVal)
236+
}
237+
}
238+
}
239+
240+
// initializationHooks runs update-metadata and bind-flags hooks. When multiple plugins bind the same
241+
// flag, one flag is used and its value is synced to all after parse; usage text is aggregated.
242+
// Returns an error if the same flag name is used with different value types (e.g. bool vs string).
170243
func initializationHooks(
171244
cmd *cobra.Command,
172245
subcommands []keySubcommandTuple,
173246
meta plugin.CLIMetadata,
174-
) *resourceOptions {
247+
) (*initHooksResult, error) {
175248
// Update metadata hook.
176249
subcmdMeta := plugin.SubcommandMetadata{
177250
Description: cmd.Long,
@@ -197,14 +270,21 @@ func initializationHooks(
197270
options = bindResourceFlags(cmd.Flags())
198271
}
199272

200-
// Bind flags hook.
273+
// Bind flags hook: each plugin binds to a temporary FlagSet, then we merge into the command so
274+
// duplicate names do not panic; values are synced after parse and help text is aggregated.
275+
duplicateValues := make(map[string][]pflag.Value)
276+
firstPluginByFlag := make(map[string]string)
201277
for _, tuple := range subcommands {
202278
if subcommand, hasFlags := tuple.subcommand.(plugin.HasFlags); hasFlags {
203-
subcommand.BindFlags(cmd.Flags())
279+
tmpSet := pflag.NewFlagSet(cmd.Name(), pflag.ExitOnError)
280+
subcommand.BindFlags(tmpSet)
281+
if err := mergeFlagSetInto(cmd.Flags(), tmpSet, duplicateValues, tuple.key, firstPluginByFlag); err != nil {
282+
return nil, err
283+
}
204284
}
205285
}
206286

207-
return options
287+
return &initHooksResult{options: options, duplicateFlagValues: duplicateValues}, nil
208288
}
209289

210290
type executionHooksFactory struct {
@@ -223,6 +303,8 @@ type executionHooksFactory struct {
223303
pluginChain []string
224304
// cliVersion is the version of the CLI.
225305
cliVersion string
306+
// duplicateFlagValues maps flag names to Values to sync from the parsed flag in PreRunE.
307+
duplicateFlagValues map[string][]pflag.Value
226308
}
227309

228310
func (factory *executionHooksFactory) forEach(cb func(subcommand plugin.Subcommand) error, errorMessage string) error {
@@ -319,7 +401,10 @@ func (factory *executionHooksFactory) preRunEFunc(
319401
options *resourceOptions,
320402
createConfig bool,
321403
) func(*cobra.Command, []string) error {
322-
return func(*cobra.Command, []string) error {
404+
return func(cmd *cobra.Command, _ []string) error {
405+
if len(factory.duplicateFlagValues) > 0 {
406+
syncDuplicateFlags(cmd.Flags(), factory.duplicateFlagValues)
407+
}
323408
if createConfig {
324409
// Check if a project configuration is already present.
325410
if err := factory.store.Load(); err == nil || !errors.Is(err, os.ErrNotExist) {

pkg/cli/cmd_helpers_test.go

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
. "github.com/onsi/ginkgo/v2"
2323
. "github.com/onsi/gomega"
2424
"github.com/spf13/cobra"
25+
"github.com/spf13/pflag"
2526

2627
"sigs.k8s.io/kubebuilder/v4/pkg/config"
2728
"sigs.k8s.io/kubebuilder/v4/pkg/machinery"
@@ -259,6 +260,183 @@ var _ = Describe("cmd_helpers", func() {
259260
Expect(result).To(BeEmpty())
260261
})
261262
})
263+
264+
Context("duplicate flag handling (mergeFlagSetInto, syncDuplicateFlags)", func() {
265+
It("should not panic when merging two FlagSets that define the same flag name (same type)", func() {
266+
dest := pflag.NewFlagSet("dest", pflag.ExitOnError)
267+
src := pflag.NewFlagSet("src", pflag.ExitOnError)
268+
duplicateValues := make(map[string][]pflag.Value)
269+
firstPluginByFlag := make(map[string]string)
270+
271+
var destBool bool
272+
var srcBool bool
273+
dest.BoolVar(&destBool, "force", false, "overwrite files (plugin A)")
274+
src.BoolVar(&srcBool, "force", false, "regenerate all files (plugin B)")
275+
276+
err := mergeFlagSetInto(dest, src, duplicateValues, "pluginB/v1", firstPluginByFlag)
277+
Expect(err).NotTo(HaveOccurred())
278+
Expect(dest.Lookup("force")).NotTo(BeNil())
279+
Expect(duplicateValues["force"]).To(HaveLen(1))
280+
})
281+
282+
It("should aggregate help text as For plugin (key): desc AND for plugin (key): desc", func() {
283+
dest := pflag.NewFlagSet("dest", pflag.ExitOnError)
284+
src := pflag.NewFlagSet("src", pflag.ExitOnError)
285+
duplicateValues := make(map[string][]pflag.Value)
286+
firstPluginByFlag := make(map[string]string)
287+
288+
var a, b bool
289+
dest.BoolVar(&a, "force", false, "overwrite files (plugin A)")
290+
src.BoolVar(&b, "force", false, "regenerate all files (plugin B)")
291+
292+
err := mergeFlagSetInto(dest, src, duplicateValues, "pluginB/v1", firstPluginByFlag)
293+
Expect(err).NotTo(HaveOccurred())
294+
295+
flag := dest.Lookup("force")
296+
Expect(flag).NotTo(BeNil())
297+
Expect(flag.Usage).To(ContainSubstring("overwrite files (plugin A)"))
298+
Expect(flag.Usage).To(ContainSubstring("AND for plugin (pluginB/v1):"))
299+
Expect(flag.Usage).To(ContainSubstring("regenerate all files (plugin B)"))
300+
})
301+
302+
It("should prefix first plugin with For plugin (key): when both flags merged via mergeFlagSetInto", func() {
303+
dest := pflag.NewFlagSet("dest", pflag.ExitOnError)
304+
pluginA := pflag.NewFlagSet("a", pflag.ExitOnError)
305+
pluginB := pflag.NewFlagSet("b", pflag.ExitOnError)
306+
duplicateValues := make(map[string][]pflag.Value)
307+
firstPluginByFlag := make(map[string]string)
308+
309+
var a, b bool
310+
pluginA.BoolVar(&a, "force", false, "overwrite files (plugin A)")
311+
pluginB.BoolVar(&b, "force", false, "regenerate all files (plugin B)")
312+
313+
Expect(mergeFlagSetInto(dest, pluginA, duplicateValues, "pluginA/v1", firstPluginByFlag)).NotTo(HaveOccurred())
314+
Expect(mergeFlagSetInto(dest, pluginB, duplicateValues, "pluginB/v1", firstPluginByFlag)).NotTo(HaveOccurred())
315+
316+
flag := dest.Lookup("force")
317+
Expect(flag).NotTo(BeNil())
318+
Expect(flag.Usage).To(Equal(
319+
"For plugin (pluginA/v1): overwrite files (plugin A) AND for plugin (pluginB/v1): regenerate all files (plugin B)"))
320+
})
321+
322+
It("should show full plugin keys in aggregated usage", func() {
323+
dest := pflag.NewFlagSet("dest", pflag.ExitOnError)
324+
goPlugin := pflag.NewFlagSet("go", pflag.ExitOnError)
325+
helmPlugin := pflag.NewFlagSet("helm", pflag.ExitOnError)
326+
duplicateValues := make(map[string][]pflag.Value)
327+
firstPluginByFlag := make(map[string]string)
328+
329+
var a, b bool
330+
goPlugin.BoolVar(&a, "force", false, "overwrite scaffolded files to apply changes (manual edits may be lost)")
331+
helmPlugin.BoolVar(&b, "force", false, "if true, regenerates all the files")
332+
333+
Expect(mergeFlagSetInto(dest, goPlugin, duplicateValues, "base.go.kubebuilder.io/v4", firstPluginByFlag)).
334+
NotTo(HaveOccurred())
335+
Expect(mergeFlagSetInto(dest, helmPlugin, duplicateValues, "helm.kubebuilder.io/v2-alpha", firstPluginByFlag)).
336+
NotTo(HaveOccurred())
337+
338+
flag := dest.Lookup("force")
339+
Expect(flag).NotTo(BeNil())
340+
expectedUsage := "For plugin (base.go.kubebuilder.io/v4): overwrite scaffolded files to apply changes " +
341+
"(manual edits may be lost) AND for plugin (helm.kubebuilder.io/v2-alpha): if true, regenerates all the files"
342+
Expect(flag.Usage).To(Equal(expectedUsage))
343+
})
344+
345+
It("should return error when same flag name is bound with different value types", func() {
346+
dest := pflag.NewFlagSet("dest", pflag.ExitOnError)
347+
src := pflag.NewFlagSet("src", pflag.ExitOnError)
348+
duplicateValues := make(map[string][]pflag.Value)
349+
firstPluginByFlag := make(map[string]string)
350+
firstPluginByFlag["flag"] = "pluginA/v1" // dest already has this flag from a previous plugin
351+
352+
var a bool
353+
var b string
354+
dest.BoolVar(&a, "flag", false, "bool usage (plugin A)")
355+
src.StringVar(&b, "flag", "", "string usage (plugin B)")
356+
357+
err := mergeFlagSetInto(dest, src, duplicateValues, "pluginB/v1", firstPluginByFlag)
358+
Expect(err).To(HaveOccurred())
359+
Expect(err.Error()).To(ContainSubstring("same flag name"))
360+
Expect(err.Error()).To(ContainSubstring("different value types"))
361+
Expect(err.Error()).To(ContainSubstring("flag"))
362+
Expect(err.Error()).To(ContainSubstring("bool"))
363+
Expect(err.Error()).To(ContainSubstring("string"))
364+
Expect(err.Error()).To(ContainSubstring("pluginA/v1"))
365+
Expect(err.Error()).To(ContainSubstring("pluginB/v1"))
366+
})
367+
368+
It("should sync parsed value to duplicate Values after syncDuplicateFlags", func() {
369+
flags := pflag.NewFlagSet("cmd", pflag.ExitOnError)
370+
var mainVal, dupVal bool
371+
flags.BoolVar(&mainVal, "force", false, "usage")
372+
tmpFS := pflag.NewFlagSet("", pflag.ExitOnError)
373+
tmpFS.BoolVar(&dupVal, "force", false, "")
374+
duplicateValues := map[string][]pflag.Value{
375+
"force": {tmpFS.Lookup("force").Value},
376+
}
377+
378+
Expect(flags.Parse([]string{"--force", "true"})).NotTo(HaveOccurred())
379+
Expect(mainVal).To(BeTrue())
380+
Expect(dupVal).To(BeFalse())
381+
382+
syncDuplicateFlags(flags, duplicateValues)
383+
Expect(dupVal).To(BeTrue())
384+
})
385+
386+
It("should give all plugins in the chain the same value for a shared flag (e.g. --force)", func() {
387+
cmdFlags := pflag.NewFlagSet("edit", pflag.ExitOnError)
388+
pluginA := pflag.NewFlagSet("pluginA", pflag.ExitOnError)
389+
pluginB := pflag.NewFlagSet("pluginB", pflag.ExitOnError)
390+
var forceA, forceB bool
391+
pluginA.BoolVar(&forceA, "force", false, "plugin A force")
392+
pluginB.BoolVar(&forceB, "force", false, "plugin B force")
393+
394+
duplicateValues := make(map[string][]pflag.Value)
395+
firstPluginByFlag := make(map[string]string)
396+
Expect(mergeFlagSetInto(cmdFlags, pluginA, duplicateValues, "pluginA/v1", firstPluginByFlag)).NotTo(HaveOccurred())
397+
Expect(mergeFlagSetInto(cmdFlags, pluginB, duplicateValues, "pluginB/v1", firstPluginByFlag)).NotTo(HaveOccurred())
398+
399+
Expect(cmdFlags.Parse([]string{"--force", "true"})).NotTo(HaveOccurred())
400+
syncDuplicateFlags(cmdFlags, duplicateValues)
401+
Expect(forceA).To(BeTrue(), "plugin A must receive the value passed by the user")
402+
Expect(forceB).To(BeTrue(), "plugin B must receive the same value as the command")
403+
})
404+
405+
It("should sync string flag value to duplicate Values", func() {
406+
flags := pflag.NewFlagSet("cmd", pflag.ExitOnError)
407+
var mainVal, dupVal string
408+
flags.StringVar(&mainVal, "name", "", "name usage")
409+
tmpFS := pflag.NewFlagSet("", pflag.ExitOnError)
410+
tmpFS.StringVar(&dupVal, "name", "", "")
411+
duplicateValues := map[string][]pflag.Value{
412+
"name": {tmpFS.Lookup("name").Value},
413+
}
414+
415+
Expect(flags.Parse([]string{"--name", "foo"})).NotTo(HaveOccurred())
416+
syncDuplicateFlags(flags, duplicateValues)
417+
Expect(dupVal).To(Equal("foo"))
418+
})
419+
420+
It("applies merge and sync for any subcommand (init, api, webhook, edit), not only edit", func() {
421+
cmd := &cobra.Command{Use: "api"}
422+
pluginA := &mockSubcommandWithForceFlag{}
423+
pluginB := &mockSubcommandWithForceFlag{}
424+
tuples := []keySubcommandTuple{
425+
{key: "pluginA.kubebuilder.io/v1", subcommand: pluginA},
426+
{key: "pluginB.kubebuilder.io/v1", subcommand: pluginB},
427+
}
428+
meta := plugin.CLIMetadata{}
429+
430+
result, err := initializationHooks(cmd, tuples, meta)
431+
Expect(err).NotTo(HaveOccurred())
432+
Expect(result.duplicateFlagValues["force"]).To(HaveLen(1), "second plugin's Value recorded as duplicate")
433+
434+
Expect(cmd.ParseFlags([]string{"--force", "true"})).NotTo(HaveOccurred())
435+
syncDuplicateFlags(cmd.Flags(), result.duplicateFlagValues)
436+
Expect(pluginA.Force).To(BeTrue(), "first plugin (flag on command) receives value")
437+
Expect(pluginB.Force).To(BeTrue(), "second plugin (duplicate) receives same value after sync")
438+
})
439+
})
262440
})
263441

264442
type mockTestSubcommand struct{}
@@ -267,6 +445,19 @@ func (m *mockTestSubcommand) Scaffold(machinery.Filesystem) error {
267445
return nil
268446
}
269447

448+
// mockSubcommandWithForceFlag implements Subcommand and HasFlags for tests with a shared flag.
449+
type mockSubcommandWithForceFlag struct {
450+
Force bool
451+
}
452+
453+
func (m *mockSubcommandWithForceFlag) Scaffold(machinery.Filesystem) error {
454+
return nil
455+
}
456+
457+
func (m *mockSubcommandWithForceFlag) BindFlags(flags *pflag.FlagSet) {
458+
flags.BoolVar(&m.Force, "force", false, "force usage")
459+
}
460+
270461
type mockPluginWithSubcommand struct {
271462
name string
272463
supportedProjectVersions []config.Version

0 commit comments

Comments
 (0)