Bind CLI flags¶
A command-line flag should override a configured value, but only when the user actually
set it. WithFlags contributes a pflag flag set to the
Store as an ordinary layer, and it contributes only the flags the user changed.
Imports used below
Add the flag set¶
Add it last. Precedence is the order sources are added, and an explicit flag is the most deliberate input a user can give:
// cmd is a *cobra.Command here, but nothing requires cobra: WithFlags takes a
// *pflag.FlagSet, so any source of one will do. See the testing recipe below.
store, err := config.NewStore(ctx,
config.WithFiles(config.OS(), "/etc/mytool/config.yaml"),
config.WithEnv("MYTOOL"),
config.WithFlags(cmd.Flags()),
)
if err != nil {
return err
}
Parse the flags before the store loads them. A flag set that has not been parsed has no changed flags, so the layer contributes nothing.
A flag left at its default cannot clobber anything¶
The flag backend walks the set with pflag.FlagSet.Visit, which iterates only the flags
that were actually changed on the command line. A flag sitting at its compiled-in default
therefore contributes no value at all. It does not appear in the layer, it does not
appear in provenance, and it cannot outrank your file.
Binding every flag regardless is a well-known trap: the file says port: 9090, nobody
passed --port, and the effective value is the flag default of 8080. The user has no
way to discover why their configuration is being ignored, because nothing in the file or
the environment explains it. Here there is no way to ask the store to contribute an
unchanged flag.
Map a flag name to a configuration key¶
By default a flag name becomes a configuration key by replacing dashes with dots, so
--log-level supplies log.level and --server-port supplies server.port. When the
two genuinely differ, name the mapping with BindFlag:
store, err := config.NewStore(ctx,
config.WithFiles(config.OS(), "config.yaml"),
config.WithFlags(cmd.Flags(),
config.BindFlag("port", "server.port"),
config.BindFlag("verbose", "log.level"),
),
)
BindFlag takes the flag name without its leading dashes. Flags you do not mention
still contribute under the derived key; BindFlag is an override, not an allow-list.
To keep a flag out of configuration altogether, such as an operational switch like
--output that is nobody's setting, bind it to the empty key:
Avoid dots inside flag names. A flag literally named --a.b maps verbatim to the key
a.b, which is rarely what anyone intends and cannot be told apart from the derived form
afterwards. A flag name keeps its case, so --Log-Level maps to Log.Level. A store that
folds keys then lower-cases it like any other key, so a flag name that differs only in case
will not give you a distinct key. In a store built with WithCaseSensitiveKeys it will, and
BindFlag("level", "Log.Level") is how a short flag name reaches a mixed-case key.
Check that a flag really is winning¶
A flag is a layer like any other, so provenance names it:
view := store.View()
if src, ok := view.Origin("server.port"); ok && src.Kind == config.SourceFlag {
fmt.Println("server.port was set by", src.Name) // --port
}
fmt.Println(view.Explain("server.port"))
// server.port = 9090 (from flag:--port); also defined in /etc/mytool/config.yaml
That is the quickest answer to "did my flag take effect?" and to "why is my config file being ignored?". See Provenance.
Flag values arrive as strings¶
The flag layer contributes each changed flag's string form, whatever the flag's Go type.
For reading that is invisible: GetInt, GetBool, GetDuration and friends coerce, so
--port 9090 reads back as the integer 9090.
Repeatable flags are the exception. A StringSlice flag renders as [a,b] in string
form, and storing that would give one garbage value rather than the two the user passed.
Slice flags therefore contribute a list, and --tag a --tag b reads back through
GetStringSlice as ["a", "b"].
Schema validation accepts it for the same reason. A field declared int, float64, bool
or duration passes when the winning value is text that reads as that type, so --port 9090
satisfies an int field. Text that does not, such as --port abc, fails:
A field declared string accepts only a string. See
Validate configuration.
Flags are never a write target¶
Flags are readable but not writable, since a flag lives for one invocation. Write routing skips the flag layer and lands the change in the highest-precedence writable layer instead.
So a write to a key the user also passed on the command line succeeds, writes the file,
and changes nothing about the resolved configuration, because the flag still wins.
Operation.Effective() reports exactly that:
plan, err := store.Plan(config.Set("server.port", 9090))
if err != nil {
return err
}
for _, op := range plan.Operations {
if !op.Effective() {
fmt.Printf("%s written to %s, but %s still wins for this run\n",
op.Change.Path, op.Target, op.ShadowedBy[len(op.ShadowedBy)-1])
}
}
See Write configuration for the full routing story.
Testing without a command¶
You do not need cobra, or a real command line, to test flag precedence. Build a
pflag.FlagSet, parse an argument slice into it, and hand it to the store:
func TestFlagBeatsFile(t *testing.T) {
fsys, err := config.Dir(t.TempDir())
require.NoError(t, err)
require.NoError(t, fsys.WriteFile("config.yaml",
[]byte("server:\n port: 8080\n"), 0o600))
flags := pflag.NewFlagSet("mytool", pflag.ContinueOnError)
flags.Int("port", 8080, "listen port")
require.NoError(t, flags.Parse([]string{"--port", "9090"}))
store, err := config.NewStore(context.Background(),
config.WithFiles(fsys, "config.yaml"),
config.WithFlags(flags, config.BindFlag("port", "server.port")),
)
require.NoError(t, err)
assert.Equal(t, 9090, store.View().GetInt("server.port"))
}
Parse nothing and the assertion flips: the file's value stands, because an unchanged flag contributes nothing.
Related¶
- Load & merge configuration: building a store and ordering sources
- Precedence & merge model: how layers fold together
- Provenance: asking which layer supplied a value
- Write configuration: why a flag can shadow a successful write