Skip to content

Commit f80f7e0

Browse files
authored
feat(cli): add Cobra binder and backend switch (#5820)
* add FlagBinder with Kingpin and Cobra implementations * support --cli-backend and EXTERNAL_DNS_CLI (default: kingpin) * add tests for binders and CLI switch Refs: #5379 Signed-off-by: Tobias Harnickell <[email protected]>
1 parent 1c11650 commit f80f7e0

File tree

4 files changed

+503
-2
lines changed

4 files changed

+503
-2
lines changed

pkg/apis/externaldns/binders.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package externaldns
18+
19+
import (
20+
"strconv"
21+
"time"
22+
23+
"github.com/alecthomas/kingpin/v2"
24+
"github.com/spf13/cobra"
25+
)
26+
27+
// FlagBinder abstracts flag registration for different CLI backends.
28+
type FlagBinder interface {
29+
StringVar(name, help, def string, target *string)
30+
BoolVar(name, help string, def bool, target *bool)
31+
DurationVar(name, help string, def time.Duration, target *time.Duration)
32+
IntVar(name, help string, def int, target *int)
33+
Int64Var(name, help string, def int64, target *int64)
34+
StringsVar(name, help string, def []string, target *[]string)
35+
EnumVar(name, help, def string, target *string, allowed ...string)
36+
}
37+
38+
// KingpinBinder implements FlagBinder using github.com/alecthomas/kingpin/v2.
39+
type KingpinBinder struct {
40+
App *kingpin.Application
41+
}
42+
43+
// NewKingpinBinder creates a FlagBinder backed by a kingpin Application.
44+
func NewKingpinBinder(app *kingpin.Application) *KingpinBinder {
45+
return &KingpinBinder{App: app}
46+
}
47+
48+
func (b *KingpinBinder) StringVar(name, help, def string, target *string) {
49+
b.App.Flag(name, help).Default(def).StringVar(target)
50+
}
51+
52+
func (b *KingpinBinder) BoolVar(name, help string, def bool, target *bool) {
53+
if def {
54+
b.App.Flag(name, help).Default("true").BoolVar(target)
55+
} else {
56+
b.App.Flag(name, help).Default("false").BoolVar(target)
57+
}
58+
}
59+
60+
func (b *KingpinBinder) DurationVar(name, help string, def time.Duration, target *time.Duration) {
61+
b.App.Flag(name, help).Default(def.String()).DurationVar(target)
62+
}
63+
64+
func (b *KingpinBinder) IntVar(name, help string, def int, target *int) {
65+
b.App.Flag(name, help).Default(strconv.Itoa(def)).IntVar(target)
66+
}
67+
68+
func (b *KingpinBinder) Int64Var(name, help string, def int64, target *int64) {
69+
b.App.Flag(name, help).Default(strconv.FormatInt(def, 10)).Int64Var(target)
70+
}
71+
72+
func (b *KingpinBinder) StringsVar(name, help string, def []string, target *[]string) {
73+
if len(def) > 0 {
74+
b.App.Flag(name, help).Default(def...).StringsVar(target)
75+
return
76+
}
77+
b.App.Flag(name, help).StringsVar(target)
78+
}
79+
80+
func (b *KingpinBinder) EnumVar(name, help, def string, target *string, allowed ...string) {
81+
b.App.Flag(name, help).Default(def).EnumVar(target, allowed...)
82+
}
83+
84+
// CobraBinder implements FlagBinder using github.com/spf13/cobra.
85+
type CobraBinder struct {
86+
Cmd *cobra.Command
87+
}
88+
89+
// NewCobraBinder creates a FlagBinder backed by a Cobra command.
90+
func NewCobraBinder(cmd *cobra.Command) *CobraBinder {
91+
return &CobraBinder{Cmd: cmd}
92+
}
93+
94+
func (b *CobraBinder) StringVar(name, help, def string, target *string) {
95+
b.Cmd.Flags().StringVar(target, name, def, help)
96+
}
97+
98+
func (b *CobraBinder) BoolVar(name, help string, def bool, target *bool) {
99+
b.Cmd.Flags().BoolVar(target, name, def, help)
100+
}
101+
102+
func (b *CobraBinder) DurationVar(name, help string, def time.Duration, target *time.Duration) {
103+
b.Cmd.Flags().DurationVar(target, name, def, help)
104+
}
105+
106+
func (b *CobraBinder) IntVar(name, help string, def int, target *int) {
107+
b.Cmd.Flags().IntVar(target, name, def, help)
108+
}
109+
110+
func (b *CobraBinder) Int64Var(name, help string, def int64, target *int64) {
111+
b.Cmd.Flags().Int64Var(target, name, def, help)
112+
}
113+
114+
func (b *CobraBinder) StringsVar(name, help string, def []string, target *[]string) {
115+
// Preserve repeatable flag semantics.
116+
b.Cmd.Flags().StringArrayVar(target, name, def, help)
117+
}
118+
119+
func (b *CobraBinder) EnumVar(name, help, def string, target *string, allowed ...string) {
120+
b.Cmd.Flags().StringVar(target, name, def, help)
121+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package externaldns
18+
19+
import (
20+
"testing"
21+
"time"
22+
23+
"github.com/alecthomas/kingpin/v2"
24+
"github.com/spf13/cobra"
25+
"github.com/stretchr/testify/assert"
26+
"github.com/stretchr/testify/require"
27+
)
28+
29+
func TestKingpinBinderParsesAllTypes(t *testing.T) {
30+
app := kingpin.New("test", "")
31+
b := NewKingpinBinder(app)
32+
33+
var (
34+
s string
35+
bval bool
36+
d time.Duration
37+
i int
38+
i64 int64
39+
ss []string
40+
e string
41+
)
42+
43+
b.StringVar("s", "string flag", "def", &s)
44+
b.BoolVar("b", "bool flag", true, &bval)
45+
b.DurationVar("d", "duration flag", 5*time.Second, &d)
46+
b.IntVar("i", "int flag", 7, &i)
47+
b.Int64Var("i64", "int64 flag", 9, &i64)
48+
b.StringsVar("ss", "strings flag", []string{"x"}, &ss)
49+
b.EnumVar("e", "enum flag", "a", &e, "a", "b")
50+
51+
_, err := app.Parse([]string{"--s=abc", "--no-b", "--d=2s", "--i=42", "--i64=64", "--ss=one", "--ss=two", "--e=b"})
52+
require.NoError(t, err)
53+
54+
assert.Equal(t, "abc", s)
55+
assert.False(t, bval)
56+
assert.Equal(t, 2*time.Second, d)
57+
assert.Equal(t, 42, i)
58+
assert.Equal(t, int64(64), i64)
59+
assert.ElementsMatch(t, []string{"one", "two"}, ss)
60+
assert.Equal(t, "b", e)
61+
}
62+
63+
func TestKingpinBinderEnumValidation(t *testing.T) {
64+
app := kingpin.New("test", "")
65+
b := NewKingpinBinder(app)
66+
67+
var e string
68+
b.EnumVar("e", "enum flag", "a", &e, "a", "b")
69+
70+
_, err := app.Parse([]string{"--e=c"})
71+
require.Error(t, err)
72+
}
73+
74+
func TestKingpinBinderStringsVarNoDefaultAndBoolDefaultFalse(t *testing.T) {
75+
app := kingpin.New("test", "")
76+
b := NewKingpinBinder(app)
77+
78+
var (
79+
ss []string
80+
b2 bool
81+
)
82+
83+
b.StringsVar("ss", "strings flag", nil, &ss)
84+
b.BoolVar("b2", "bool2 flag", false, &b2)
85+
86+
_, err := app.Parse([]string{})
87+
require.NoError(t, err)
88+
89+
assert.Empty(t, ss)
90+
assert.False(t, b2)
91+
}
92+
93+
func TestCobraBinderParsesAllTypes(t *testing.T) {
94+
cmd := &cobra.Command{Use: "test"}
95+
b := NewCobraBinder(cmd)
96+
97+
var (
98+
s string
99+
bval bool
100+
d time.Duration
101+
i int
102+
i64 int64
103+
ss []string
104+
e string
105+
)
106+
107+
b.StringVar("s", "string flag", "def", &s)
108+
b.BoolVar("b", "bool flag", true, &bval)
109+
b.DurationVar("d", "duration flag", 5*time.Second, &d)
110+
b.IntVar("i", "int flag", 7, &i)
111+
b.Int64Var("i64", "int64 flag", 9, &i64)
112+
b.StringsVar("ss", "strings flag", []string{"x"}, &ss)
113+
b.EnumVar("e", "enum flag", "a", &e, "a", "b")
114+
115+
cmd.SetArgs([]string{"--s=abc", "--b=false", "--d=2s", "--i=42", "--i64=64", "--ss=one", "--ss=two", "--e=b"})
116+
err := cmd.Execute()
117+
require.NoError(t, err)
118+
119+
assert.Equal(t, "abc", s)
120+
assert.False(t, bval)
121+
assert.Equal(t, 2*time.Second, d)
122+
assert.Equal(t, 42, i)
123+
assert.Equal(t, int64(64), i64)
124+
assert.ElementsMatch(t, []string{"one", "two"}, ss)
125+
assert.Equal(t, "b", e)
126+
}
127+
128+
func TestCobraBinderEnumNotValidatedHere(t *testing.T) {
129+
cmd := &cobra.Command{Use: "test"}
130+
b := NewCobraBinder(cmd)
131+
132+
var e string
133+
b.EnumVar("e", "enum flag", "a", &e, "a", "b")
134+
135+
cmd.SetArgs([]string{"--e=c"})
136+
err := cmd.Execute()
137+
require.NoError(t, err)
138+
assert.Equal(t, "c", e)
139+
}

pkg/apis/externaldns/types.go

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package externaldns
1818

1919
import (
2020
"fmt"
21+
"os"
2122
"reflect"
2223
"regexp"
2324
"strconv"
@@ -30,6 +31,7 @@ import (
3031

3132
"github.com/alecthomas/kingpin/v2"
3233
"github.com/sirupsen/logrus"
34+
"github.com/spf13/cobra"
3335
)
3436

3537
const (
@@ -421,16 +423,80 @@ func allLogLevelsAsStrings() []string {
421423

422424
// ParseFlags adds and parses flags from command line
423425
func (cfg *Config) ParseFlags(args []string) error {
424-
app := App(cfg)
426+
backend := ""
427+
pruned := make([]string, 0, len(args))
428+
skipNext := false
429+
for i := 0; i < len(args); i++ {
430+
if skipNext {
431+
skipNext = false
432+
continue
433+
}
434+
a := args[i]
435+
if strings.HasPrefix(a, "--cli-backend") {
436+
val := ""
437+
if a == "--cli-backend" {
438+
if i+1 < len(args) {
439+
val = args[i+1]
440+
skipNext = true
441+
}
442+
} else if strings.HasPrefix(a, "--cli-backend=") {
443+
val = strings.TrimPrefix(a, "--cli-backend=")
444+
}
445+
if val != "" {
446+
backend = val
447+
}
448+
continue
449+
}
450+
pruned = append(pruned, a)
451+
}
452+
if backend == "" {
453+
backend = os.Getenv("EXTERNAL_DNS_CLI")
454+
}
455+
if strings.EqualFold(backend, "cobra") {
456+
cmd := newCobraCommand(cfg)
457+
cmd.SetArgs(pruned)
458+
if err := cmd.Execute(); err != nil {
459+
return err
460+
}
461+
return nil
462+
}
425463

426-
_, err := app.Parse(args)
464+
app := App(cfg)
465+
_, err := app.Parse(pruned)
427466
if err != nil {
428467
return err
429468
}
430469

431470
return nil
432471
}
433472

473+
func newCobraCommand(cfg *Config) *cobra.Command {
474+
cmd := &cobra.Command{
475+
Use: "external-dns",
476+
Short: "ExternalDNS synchronizes exposed Kubernetes Services and Ingresses with DNS providers.",
477+
SilenceUsage: true,
478+
SilenceErrors: true,
479+
RunE: func(cmd *cobra.Command, args []string) error {
480+
return nil
481+
},
482+
}
483+
484+
b := NewCobraBinder(cmd)
485+
486+
b.EnumVar("provider", "The DNS provider where the DNS records will be created.", defaultConfig.Provider, &cfg.Provider)
487+
b.StringsVar("source", "The resource types that are queried for endpoints; specify multiple times for multiple sources.", cfg.Sources, &cfg.Sources)
488+
489+
b.StringVar("server", "The Kubernetes API server to connect to (default: auto-detect)", defaultConfig.APIServerURL, &cfg.APIServerURL)
490+
b.StringVar("kubeconfig", "Retrieve target cluster configuration from a Kubernetes configuration file (default: auto-detect)", defaultConfig.KubeConfig, &cfg.KubeConfig)
491+
b.DurationVar("request-timeout", "Request timeout when calling Kubernetes APIs. 0s means no timeout", defaultConfig.RequestTimeout, &cfg.RequestTimeout)
492+
493+
b.StringVar("namespace", "Limit resources queried for endpoints to a specific namespace (default: all namespaces)", defaultConfig.Namespace, &cfg.Namespace)
494+
b.StringsVar("domain-filter", "Limit possible target zones by domain suffix (optional)", defaultConfig.DomainFilter, &cfg.DomainFilter)
495+
b.StringVar("openshift-router-name", "if source is openshift-route then you can pass the ingress controller name.", cfg.OCPRouterName, &cfg.OCPRouterName)
496+
497+
return cmd
498+
}
499+
434500
func (cfg *Config) AddSourceWrapper(name string) {
435501
if cfg.sourceWrappers == nil {
436502
cfg.sourceWrappers = make(map[string]bool)

0 commit comments

Comments
 (0)