From 804c070fafef5c642f1ccaf8341f5cc9722beb13 Mon Sep 17 00:00:00 2001 From: Ryan Tinianov Date: Thu, 1 Oct 2026 11:14:55 -0400 Subject: [PATCH] Add profiles to x/config/cli --- x/config/cli/README.md | 3 + x/config/cli/binder.go | 4 +- x/config/cli/entries.go | 139 ++++-- x/config/cli/entry.go | 42 +- .../cli/examples/nested_profiles/README.md | 23 + .../nested_profiles/appconfig/config.go | 27 ++ .../appconfig/doccomments_gen.go | 21 + .../nested_profiles/appconfig/gen/main.go | 21 + .../cli/examples/nested_profiles/example.toml | 5 + x/config/cli/examples/nested_profiles/main.go | 64 +++ x/config/cli/examples/profiles/README.md | 44 ++ .../cli/examples/profiles/appconfig/config.go | 19 + .../profiles/appconfig/doccomments_gen.go | 14 + .../examples/profiles/appconfig/gen/main.go | 21 + x/config/cli/examples/profiles/example.toml | 3 + x/config/cli/examples/profiles/main.go | 61 +++ x/config/cli/options.go | 3 +- x/config/cli/profile.go | 388 ++++++++++++++++ x/config/cli/profile_test.go | 432 ++++++++++++++++++ x/config/cli/register.go | 3 +- x/config/go.mod | 1 + x/config/go.sum | 12 + 22 files changed, 1294 insertions(+), 56 deletions(-) create mode 100644 x/config/cli/examples/nested_profiles/README.md create mode 100644 x/config/cli/examples/nested_profiles/appconfig/config.go create mode 100644 x/config/cli/examples/nested_profiles/appconfig/doccomments_gen.go create mode 100644 x/config/cli/examples/nested_profiles/appconfig/gen/main.go create mode 100644 x/config/cli/examples/nested_profiles/example.toml create mode 100644 x/config/cli/examples/nested_profiles/main.go create mode 100644 x/config/cli/examples/profiles/README.md create mode 100644 x/config/cli/examples/profiles/appconfig/config.go create mode 100644 x/config/cli/examples/profiles/appconfig/doccomments_gen.go create mode 100644 x/config/cli/examples/profiles/appconfig/gen/main.go create mode 100644 x/config/cli/examples/profiles/example.toml create mode 100644 x/config/cli/examples/profiles/main.go create mode 100644 x/config/cli/profile.go create mode 100644 x/config/cli/profile_test.go diff --git a/x/config/cli/README.md b/x/config/cli/README.md index 5fdd1ab3e5..34909e48fd 100644 --- a/x/config/cli/README.md +++ b/x/config/cli/README.md @@ -147,4 +147,7 @@ takes a string. - [`Options`](options.go): env var prefixes, base config, default config path. - [`New` / `Binder.Register`](binder.go): registration, on the root or a subcommand. Flags are persistent, so subcommands take them too. +- [`Profile` / `ProfileWithSelector`](profile.go): build a struct's or a section's defaults with a + function picked by one of its top-level fields; see [`examples/profiles`](examples/profiles) and + [`examples/nested_profiles`](examples/nested_profiles). - [`examples/`](examples): runnable programs. diff --git a/x/config/cli/binder.go b/x/config/cli/binder.go index a03bb2746a..1654e22036 100644 --- a/x/config/cli/binder.go +++ b/x/config/cli/binder.go @@ -58,8 +58,8 @@ func New(opts Options) (*Binder, error) { return b, nil } -// Register attaches target to cmd. Before cmd or a subcommand runs, target is filled from flags, env vars, and config -// files, then its `validate` tags are checked. +// Register attaches target to cmd. Before cmd or a subcommand runs, target is filled from flags, env vars, config +// files, and any [Profile], then its `validate` tags are checked. // // Scalars, durations, []byte, [encoding.TextUnmarshaler] types, and lists and maps of those get a persistent flag and // env vars; other fields are config file only. A list of pointers or interfaces, a map of them, and a map whose keys diff --git a/x/config/cli/entries.go b/x/config/cli/entries.go index 8939078e92..536b15ed03 100644 --- a/x/config/cli/entries.go +++ b/x/config/cli/entries.go @@ -256,48 +256,93 @@ func overlap(a, b, placeholder, sep string) (string, bool) { // entrySet is one value a source sets inside a map, such as rpc in entry mainnet. type entrySet struct { - leaf *entryLeaf - keys []reflect.Value + leaf *entryLeaf + keys []reflect.Value + // texts holds the leading keys still to resolve, named by env vars; keys holds the rest, already parsed. + texts []string value reflect.Value source string // rank is the env var prefix's index, as the first prefix set wins. rank int } +// Keys named by an env var resolve against the map the entry layers over, which holds every source below env vars. +func (s entrySet) resolve(m reflect.Value) ([]reflect.Value, error) { + if s.texts == nil { + return s.keys, nil + } + + keys := make([]reflect.Value, 0, len(s.texts)+len(s.keys)) + for i, text := range s.texts { + keyText, err := envKeyText(entryMap(m, s.leaf, keys), text) + if err != nil { + return nil, fmt.Errorf("%s: %w", s.source, err) + } + + key, err := mapKey(s.leaf.keyTypes[i], keyText, map[any]string{}) + if err != nil { + return nil, fmt.Errorf("%s: %w", s.source, err) + } + + keys = append(keys, key) + } + + return append(keys, s.keys...), nil +} + // decodeEntries layers a map's sources lowest first, each setting whole entries or values inside them, so -// --chains.mainnet.rpc changes only rpc in the file's mainnet. -func (e *typedEntry[T]) decodeEntries(k leafKey, cc commandConfig, dst reflect.Value) error { +// --chains.mainnet.rpc changes only rpc in the file's mainnet. It returns how the sources layer over a map, nil if +// none set it. +func (e *typedEntry[T]) decodeEntries(k leafKey, cc commandConfig, dst reflect.Value) (sourceLayer, error) { t := commentparsing.DerefType(k.goType) - m := mergeMaps(t, readField(dst, k.goPath)) - changed := false + var file reflect.Value if raw, ok := cc.keys.lookup(cc.fileValues, k.fileKey); ok { - file, err := fromFile(t, raw) - if err != nil { - return err + var err error + if file, err = fromFile(t, raw); err != nil { + return nil, err } - - m, changed = mergeMaps(t, m, file), true } - envSets, err := e.envEntrySets(k, m) + envSets, err := e.envEntrySets(k) if err != nil { - return err + return nil, err } - for _, layer := range [][]entrySet{envSets, e.flagEntrySets(k, cc.flags)} { - if layer, err = oneValueEach(layer); err != nil { - return err - } + layers := [][]entrySet{envSets, e.flagEntrySets(k, cc.flags)} + if !file.IsValid() && len(layers[0]) == 0 && len(layers[1]) == 0 { + return nil, nil + } - for _, s := range layer { - setEntry(m, s.leaf, s.keys, 0, s.value) + layer := func(under reflect.Value) (reflect.Value, error) { + m := mergeMaps(t, under) + if file.IsValid() { + m = mergeMaps(t, m, file) } - changed = changed || len(layer) > 0 - } + for _, sets := range layers { + // Resolved against m, before any set entry can add a key another set's name would then match. + resolved := make([]entrySet, 0, len(sets)) + for _, s := range sets { + keys, kerr := s.resolve(m) + if kerr != nil { + return reflect.Value{}, kerr + } - if !changed { - return nil + s.keys, s.texts = keys, nil + resolved = append(resolved, s) + } + + unique, uerr := oneValueEach(resolved) + if uerr != nil { + return reflect.Value{}, uerr + } + + for _, s := range unique { + setEntry(m, s.leaf, s.keys, 0, s.value) + } + } + + return m, nil } f := allocateField(dst, k.goPath) @@ -309,12 +354,22 @@ func (e *typedEntry[T]) decodeEntries(k leafKey, cc commandConfig, dst reflect.V f = f.Elem() } - f.Set(m) - return nil + // A profile replaces its section's maps, so the keys an env var names are resolved against the profile's map + // when it applies this layer, not the one here. + if slices.ContainsFunc(e.profileSelectors, func(s leafKey) bool { return inScope(k, s.goPath[:len(s.goPath)-1]) }) { + return layer, nil + } + + v, err := layer(readField(dst, k.goPath)) + if err != nil { + return nil, err + } + + f.Set(v) + return layer, nil } -// Keys in env var names are matched against base's, which holds every source below env vars. -func (e *typedEntry[T]) envEntrySets(k leafKey, base reflect.Value) ([]entrySet, error) { +func (e *typedEntry[T]) envEntrySets(k leafKey) ([]entrySet, error) { var sets []entrySet if k.flag != nil { for rank, name := range e.envVars(k) { @@ -324,7 +379,7 @@ func (e *typedEntry[T]) envEntrySets(k leafKey, base reflect.Value) ([]entrySet, return nil, fmt.Errorf("invalid value %q for %s: %w", s, name, err) } - sets = splitEntries(k.each, nil, val, name, rank) + sets = splitEntries(k.each, nil, nil, val, name, rank) break } } @@ -343,24 +398,12 @@ func (e *typedEntry[T]) envEntrySets(k leafKey, base reflect.Value) ([]entrySet, continue } - keys := make([]reflect.Value, len(texts)) - for i, text := range texts { - keyText, err := envKeyText(entryMap(base, leaf, keys[:i]), text) - if err != nil { - return nil, fmt.Errorf("%s: %w", name, err) - } - - if keys[i], err = mapKey(leaf.keyTypes[i], keyText, map[any]string{}); err != nil { - return nil, fmt.Errorf("%s: %w", name, err) - } - } - val, err := parseText(leaf.goType, s) if err != nil { return nil, fmt.Errorf("invalid value %q for %s: %w", s, name, err) } - sets = append(sets, entrySets(leaf, keys, val, name, rank)...) + sets = append(sets, entrySets(leaf, nil, texts, val, name, rank)...) } return sets, nil @@ -381,12 +424,12 @@ func (e *typedEntry[T]) envEntry(k leafKey, name string) (*entryLeaf, int, []str func (e *typedEntry[T]) flagEntrySets(k leafKey, flags *pflag.FlagSet) []entrySet { var sets []entrySet if k.flag != nil && k.flag.Changed { - sets = splitEntries(k.each, nil, k.value(), "--"+k.flagName, 0) + sets = splitEntries(k.each, nil, nil, k.value(), "--"+k.flagName, 0) } flags.Visit(func(f *pflag.Flag) { if ef, ok := e.b.entryFlags[f]; ok && slices.Contains(k.entries, ef.leaf) { - sets = append(sets, entrySets(ef.leaf, ef.keys, ef.value(), "--"+f.Name, 0)...) + sets = append(sets, entrySets(ef.leaf, ef.keys, nil, ef.value(), "--"+f.Name, 0)...) } }) @@ -439,22 +482,22 @@ func readField(v reflect.Value, path []string) reflect.Value { // Splits a whole map into its entries: --labels a=1,b=2 becomes the same as --labels.a 1 --labels.b 2, so giving // --labels a=1 --labels.a 3 is caught as setting it twice. -func splitEntries(each *entryLeaf, keys []reflect.Value, m reflect.Value, source string, rank int) []entrySet { +func splitEntries(each *entryLeaf, keys []reflect.Value, texts []string, m reflect.Value, source string, rank int) []entrySet { var sets []entrySet for key, val := range m.Seq2() { entrySource := fmt.Sprintf("%s (key %q)", source, textOf(key)) - sets = append(sets, entrySets(each, append(slices.Clone(keys), key), val, entrySource, rank)...) + sets = append(sets, entrySets(each, append(slices.Clone(keys), key), texts, val, entrySource, rank)...) } return sets } -func entrySets(leaf *entryLeaf, keys []reflect.Value, val reflect.Value, source string, rank int) []entrySet { +func entrySets(leaf *entryLeaf, keys []reflect.Value, texts []string, val reflect.Value, source string, rank int) []entrySet { if leaf.each != nil && val.Kind() == reflect.Map { - return splitEntries(leaf.each, keys, val, source, rank) + return splitEntries(leaf.each, keys, texts, val, source, rank) } - return []entrySet{{leaf, keys, val, source, rank}} + return []entrySet{{leaf: leaf, keys: keys, texts: texts, value: val, source: source, rank: rank}} } // Within one source, two values for one entry are an error, as two keys for one are. Across env var prefixes the diff --git a/x/config/cli/entry.go b/x/config/cli/entry.go index dca6b8b570..441a43ae72 100644 --- a/x/config/cli/entry.go +++ b/x/config/cli/entry.go @@ -61,6 +61,9 @@ type leafKey struct { // entries set values inside a map's entries, such as chains..rpc; each is the one that sets a whole entry. entries []*entryLeaf each *entryLeaf + + // required is a `required` rule on the field, which a profile's help leaves to the sources. + required bool } type typedEntry[T any] struct { @@ -72,6 +75,13 @@ type typedEntry[T any] struct { // ns, if set, is the first segment of every key. ns string + // profiles run after the sources are decoded, given the leaves a source set. + profiles []func(supplied map[string]sourceLayer) error + + // profileSelectors holds each registered profile's selector, so a new one overlapping its section fails Register: + // each profile rebuilds its whole section, so two on one section would drop each other's values. + profileSelectors []leafKey + undocumentedKeys []string } @@ -226,14 +236,25 @@ func (e *typedEntry[T]) parentType(structNS string) reflect.Type { return t } +// sourceLayer puts a map's source values over under, key by key, since maps merge by key at every source. A value that +// isn't a map has none: its source replaces it whole. Keys an env var names resolve against under, which can hold keys +// a profile added, so applying one can fail. +type sourceLayer func(under reflect.Value) (reflect.Value, error) + func (e *typedEntry[T]) decode(cc commandConfig) error { dst := reflect.ValueOf(e.dst).Elem() + supplied := map[string]sourceLayer{} for _, k := range e.leaves { if len(k.entries) > 0 { - if err := e.decodeEntries(k, cc, dst); err != nil { + layer, err := e.decodeEntries(k, cc, dst) + if err != nil { return fmt.Errorf("%s: %w", k.key, err) } + if layer != nil { + supplied[k.key] = layer + } + continue } @@ -257,14 +278,29 @@ func (e *typedEntry[T]) decode(cc commandConfig) error { if mergesByKey(f.Type(), e.b.opts.Markup) { // The struct's own entries first, then sources lowest precedence first, so the highest wins a key. - vals = append(vals, f) slices.Reverse(vals) - f.Set(mergeMaps(f.Type(), vals...)) + t := f.Type() + supplied[k.key] = func(under reflect.Value) (reflect.Value, error) { + return mergeMaps(t, append([]reflect.Value{under}, vals...)...), nil + } + v, err := supplied[k.key](f) + if err != nil { + return fmt.Errorf("%s: %w", k.key, err) + } + + f.Set(v) } else { + supplied[k.key] = nil f.Set(vals[0]) } } + for _, apply := range e.profiles { + if err := apply(supplied); err != nil { + return err + } + } + return nil } diff --git a/x/config/cli/examples/nested_profiles/README.md b/x/config/cli/examples/nested_profiles/README.md new file mode 100644 index 0000000000..06f5d50460 --- /dev/null +++ b/x/config/cli/examples/nested_profiles/README.md @@ -0,0 +1,23 @@ +# Nested profiles + +[`profiles`](../profiles) registers the chain on its own, so its profile builds the whole struct. Here the chain is one +section of a larger `Config`, registered whole, so a config file holds both `LogLevel` and `[Chain]`. +`cli.ProfileWithSelector` picks the `Chain` section, and `Chain.ID` (a top-level field of that section) picks a function +from `chains` in `main.go`. The profiles are the same as in `profiles`: they set the chain's `BlockTime`, and for chains 1 +and 137 its `RPC`, and only chain 137 replaces the default `Finality` of 64. + +A profile only builds its section: `LogLevel` comes from its default and the sources, whatever the chain. + +```sh +# chain 1 and its profile; log-level keeps its default +go run . --chain.id 1 + +# a source outside the section, alongside a profile +go run . --chain.id 137 --log-level debug + +# TOML values: log level and chain 137, with its own RPC +go run . --config example.toml + +# each profile and its values +go run . --help +``` diff --git a/x/config/cli/examples/nested_profiles/appconfig/config.go b/x/config/cli/examples/nested_profiles/appconfig/config.go new file mode 100644 index 0000000000..2f396d1d4a --- /dev/null +++ b/x/config/cli/examples/nested_profiles/appconfig/config.go @@ -0,0 +1,27 @@ +package appconfig + +//go:generate go run ./gen + +import "github.com/smartcontractkit/chainlink-common/pkg/config" + +type ChainConfig struct { + // ID is the chain to connect to. A built-in chain fills in the fields below. + ID uint64 `validate:"required"` + + // RPC is the node to dial. + RPC string `validate:"required"` + + // BlockTime is how often the chain produces a block. + BlockTime config.Duration + + // Finality is how many blocks until a block is final. + Finality uint32 +} + +type Config struct { + // LogLevel is how much to log. No profile touches it. + LogLevel string + + // Chain is the chain configuration. + Chain ChainConfig +} diff --git a/x/config/cli/examples/nested_profiles/appconfig/doccomments_gen.go b/x/config/cli/examples/nested_profiles/appconfig/doccomments_gen.go new file mode 100644 index 0000000000..f6e1e08cd4 --- /dev/null +++ b/x/config/cli/examples/nested_profiles/appconfig/doccomments_gen.go @@ -0,0 +1,21 @@ +// Code generated by github.com/smartcontractkit/chainlink-common/x/config/cli/examples/nested_profiles/appconfig/gen, DO NOT EDIT. + +package appconfig + +import "github.com/smartcontractkit/chainlink-common/x/config/commentparsing" + +func (ChainConfig) DocComments() map[string]commentparsing.FieldDoc { + return map[string]commentparsing.FieldDoc{ + "BlockTime": {Comment: "BlockTime is how often the chain produces a block."}, + "Finality": {Comment: "Finality is how many blocks until a block is final."}, + "ID": {Comment: "ID is the chain to connect to. A built-in chain fills in the fields below."}, + "RPC": {Comment: "RPC is the node to dial."}, + } +} + +func (Config) DocComments() map[string]commentparsing.FieldDoc { + return map[string]commentparsing.FieldDoc{ + "Chain": {Comment: "Chain is the chain configuration."}, + "LogLevel": {Comment: "LogLevel is how much to log. No profile touches it."}, + } +} diff --git a/x/config/cli/examples/nested_profiles/appconfig/gen/main.go b/x/config/cli/examples/nested_profiles/appconfig/gen/main.go new file mode 100644 index 0000000000..d9cc6d0fab --- /dev/null +++ b/x/config/cli/examples/nested_profiles/appconfig/gen/main.go @@ -0,0 +1,21 @@ +package main + +import ( + "log" + + "github.com/smartcontractkit/chainlink-common/x/config/cli/examples/nested_profiles/appconfig" + "github.com/smartcontractkit/chainlink-common/x/config/commentparsing" + "github.com/smartcontractkit/chainlink-common/x/config/markup/tomlmarkup" +) + +// Generate the comments for our config so that the CLI can use them. +func main() { + if err := commentparsing.Run(commentparsing.RunArgs{ + Markup: tomlmarkup.New(), + Roots: []any{&appconfig.Config{}}, + Tool: "github.com/smartcontractkit/chainlink-common/x/config/cli/examples/nested_profiles/appconfig/gen", + LocalPrefix: "github.com/smartcontractkit", + }); err != nil { + log.Fatal(err) + } +} diff --git a/x/config/cli/examples/nested_profiles/example.toml b/x/config/cli/examples/nested_profiles/example.toml new file mode 100644 index 0000000000..61066b6915 --- /dev/null +++ b/x/config/cli/examples/nested_profiles/example.toml @@ -0,0 +1,5 @@ +LogLevel = "debug" + +[Chain] +ID = 137 +RPC = "https://my.otherchain.example.com" diff --git a/x/config/cli/examples/nested_profiles/main.go b/x/config/cli/examples/nested_profiles/main.go new file mode 100644 index 0000000000..2503a818f6 --- /dev/null +++ b/x/config/cli/examples/nested_profiles/main.go @@ -0,0 +1,64 @@ +package main + +import ( + "fmt" + "log" + "time" + + "github.com/spf13/cobra" + + "github.com/smartcontractkit/chainlink-common/pkg/config" + "github.com/smartcontractkit/chainlink-common/x/config/cli" + "github.com/smartcontractkit/chainlink-common/x/config/cli/examples/nested_profiles/appconfig" + "github.com/smartcontractkit/chainlink-common/x/config/markup/tomlmarkup" +) + +var chains = map[uint64]func(*appconfig.ChainConfig){ + 1: func(c *appconfig.ChainConfig) { + c.RPC = "https://ethereum.example.com" + c.BlockTime = *config.MustNewDuration(12 * time.Second) + }, + // No default RPC, so running chain 10 needs one from a source. + 10: func(c *appconfig.ChainConfig) { + c.BlockTime = *config.MustNewDuration(2 * time.Second) + }, + 137: func(c *appconfig.ChainConfig) { + c.RPC = "https://otherchain.example.com" + c.BlockTime = *config.MustNewDuration(2 * time.Second) + c.Finality = 128 + }, +} + +// Chains 1 and 10 keep this default finality; 137 replaces it. +var cfg = appconfig.Config{LogLevel: "info", Chain: appconfig.ChainConfig{Finality: 64}} + +func main() { + root := &cobra.Command{ + Use: "nested_profiles", + Short: "built-in defaults for a section, picked by one of its fields", + RunE: func(*cobra.Command, []string) error { + fmt.Printf("log-level = %s\n", cfg.LogLevel) + fmt.Printf("chain.id = %d\n", cfg.Chain.ID) + fmt.Printf("chain.rpc = %s\n", cfg.Chain.RPC) + fmt.Printf("chain.block-time = %s\n", cfg.Chain.BlockTime) + fmt.Printf("chain.finality = %d\n", cfg.Chain.Finality) + return nil + }, + } + + b, err := cli.New(cli.Options{Markup: tomlmarkup.New(), Prefixes: []string{"APP"}}) + if err == nil { + err = b.Register(root, &cfg, cli.ProfileWithSelector( + func(c *appconfig.Config) *appconfig.ChainConfig { return &c.Chain }, + func(c *appconfig.ChainConfig) *uint64 { return &c.ID }, + chains)) + } + + if err != nil { + log.Fatal(err) + } + + if err = root.Execute(); err != nil { + log.Fatal(err) + } +} diff --git a/x/config/cli/examples/profiles/README.md b/x/config/cli/examples/profiles/README.md new file mode 100644 index 0000000000..4e21805941 --- /dev/null +++ b/x/config/cli/examples/profiles/README.md @@ -0,0 +1,44 @@ +# Profiles + +A profile is a function that builds a registered struct from its defaults, picked by one of its top-level fields. Here, +`ChainConfig` is registered in the `Chain` namespace, and `Chain.ID` picks a function from `chains` in `main.go`, which +sets the chain's `BlockTime`, and for chains 1 and 137 its `RPC`. Chains 1 and 10 leave `Finality` alone, so both share +the default of 64 from `chain`; chain 137 replaces it with 128. A profile can set any value, zero included. To profile +a section of a larger struct instead, see [`nested_profiles`](../nested_profiles). + +`RPC` is required, and chain 10's profile doesn't provide one, so chain 10 needs an RPC from a flag, env var, or config +file. + +- Any source still wins: a flag, env var, or config file value for a field replaces the profile's. +- An ID with no profile changes nothing, so its fields keep the registered defaults unless a source sets them. +- `Chain.ID` is tagged `validate:"required"`, so a source must pick the chain; there is no default. +- `--help` lists each profile with the values it gives, defaults included. + +```sh +# no chain picked: fails, naming every way to set it +go run . + +# chain 1 and its profile +go run . --chain.id 1 + +# chain 10 without an RPC: fails, since its profile doesn't provide one +go run . --chain.id 10 + +# chain 10 with an RPC; it shares the default finality +go run . --chain.id 10 --chain.rpc https://differentchain.example.com + +# a chain whose profile replaces the default finality +go run . --chain.id 137 + +# a profile with one field overridden +go run . --chain.id 137 --chain.finality 10 + +# a chain with no profile +APP_CHAIN_ID=999 go run . --chain.rpc https://local.example.com + +# TOML values: chain 137, with its own RPC +go run . --config example.toml + +# each profile and its values +go run . --help +``` diff --git a/x/config/cli/examples/profiles/appconfig/config.go b/x/config/cli/examples/profiles/appconfig/config.go new file mode 100644 index 0000000000..421883701d --- /dev/null +++ b/x/config/cli/examples/profiles/appconfig/config.go @@ -0,0 +1,19 @@ +package appconfig + +//go:generate go run ./gen + +import "github.com/smartcontractkit/chainlink-common/pkg/config" + +type ChainConfig struct { + // ID is the chain to connect to. A built-in chain fills in the fields below. + ID uint64 `validate:"required"` + + // RPC is the node to dial. + RPC string `validate:"required"` + + // BlockTime is how often the chain produces a block. + BlockTime config.Duration + + // Finality is how many blocks until a block is final. + Finality uint32 +} diff --git a/x/config/cli/examples/profiles/appconfig/doccomments_gen.go b/x/config/cli/examples/profiles/appconfig/doccomments_gen.go new file mode 100644 index 0000000000..6c4aca88fa --- /dev/null +++ b/x/config/cli/examples/profiles/appconfig/doccomments_gen.go @@ -0,0 +1,14 @@ +// Code generated by github.com/smartcontractkit/chainlink-common/x/config/cli/examples/profiles/appconfig/gen, DO NOT EDIT. + +package appconfig + +import "github.com/smartcontractkit/chainlink-common/x/config/commentparsing" + +func (ChainConfig) DocComments() map[string]commentparsing.FieldDoc { + return map[string]commentparsing.FieldDoc{ + "BlockTime": {Comment: "BlockTime is how often the chain produces a block."}, + "Finality": {Comment: "Finality is how many blocks until a block is final."}, + "ID": {Comment: "ID is the chain to connect to. A built-in chain fills in the fields below."}, + "RPC": {Comment: "RPC is the node to dial."}, + } +} diff --git a/x/config/cli/examples/profiles/appconfig/gen/main.go b/x/config/cli/examples/profiles/appconfig/gen/main.go new file mode 100644 index 0000000000..514de7c47f --- /dev/null +++ b/x/config/cli/examples/profiles/appconfig/gen/main.go @@ -0,0 +1,21 @@ +package main + +import ( + "log" + + "github.com/smartcontractkit/chainlink-common/x/config/cli/examples/profiles/appconfig" + "github.com/smartcontractkit/chainlink-common/x/config/commentparsing" + "github.com/smartcontractkit/chainlink-common/x/config/markup/tomlmarkup" +) + +// Generate the comments for our config so that the CLI can use them. +func main() { + if err := commentparsing.Run(commentparsing.RunArgs{ + Markup: tomlmarkup.New(), + Roots: []any{&appconfig.ChainConfig{}}, + Tool: "github.com/smartcontractkit/chainlink-common/x/config/cli/examples/profiles/appconfig/gen", + LocalPrefix: "github.com/smartcontractkit", + }); err != nil { + log.Fatal(err) + } +} diff --git a/x/config/cli/examples/profiles/example.toml b/x/config/cli/examples/profiles/example.toml new file mode 100644 index 0000000000..eddcc1545d --- /dev/null +++ b/x/config/cli/examples/profiles/example.toml @@ -0,0 +1,3 @@ +[Chain] +ID = 137 +RPC = "https://my.otherchain.example.com" diff --git a/x/config/cli/examples/profiles/main.go b/x/config/cli/examples/profiles/main.go new file mode 100644 index 0000000000..310e69afd2 --- /dev/null +++ b/x/config/cli/examples/profiles/main.go @@ -0,0 +1,61 @@ +package main + +import ( + "fmt" + "log" + "time" + + "github.com/spf13/cobra" + + "github.com/smartcontractkit/chainlink-common/pkg/config" + "github.com/smartcontractkit/chainlink-common/x/config/cli" + "github.com/smartcontractkit/chainlink-common/x/config/cli/examples/profiles/appconfig" + "github.com/smartcontractkit/chainlink-common/x/config/markup/tomlmarkup" +) + +var chains = map[uint64]func(*appconfig.ChainConfig){ + 1: func(c *appconfig.ChainConfig) { + c.RPC = "https://ethereum.example.com" + c.BlockTime = *config.MustNewDuration(12 * time.Second) + }, + // No default RPC, so running chain 10 needs one from a source. + 10: func(c *appconfig.ChainConfig) { + c.BlockTime = *config.MustNewDuration(2 * time.Second) + }, + 137: func(c *appconfig.ChainConfig) { + c.RPC = "https://otherchain.example.com" + c.BlockTime = *config.MustNewDuration(2 * time.Second) + c.Finality = 128 + }, +} + +// Chains 1 and 10 keep this default finality; 137 replaces it. +var chain = appconfig.ChainConfig{Finality: 64} + +func main() { + root := &cobra.Command{ + Use: "profiles", + Short: "built-in defaults picked by a field's value", + RunE: func(*cobra.Command, []string) error { + fmt.Printf("chain.id = %d\n", chain.ID) + fmt.Printf("chain.rpc = %s\n", chain.RPC) + fmt.Printf("chain.block-time = %s\n", chain.BlockTime) + fmt.Printf("chain.finality = %d\n", chain.Finality) + return nil + }, + } + + b, err := cli.New(cli.Options{Markup: tomlmarkup.New(), Prefixes: []string{"APP"}}) + if err == nil { + err = b.RegisterInNamespace(root, "Chain", &chain, cli.Profile( + func(c *appconfig.ChainConfig) *uint64 { return &c.ID }, chains)) + } + + if err != nil { + log.Fatal(err) + } + + if err = root.Execute(); err != nil { + log.Fatal(err) + } +} diff --git a/x/config/cli/options.go b/x/config/cli/options.go index bcee30e3f2..11382fe34d 100644 --- a/x/config/cli/options.go +++ b/x/config/cli/options.go @@ -19,8 +19,7 @@ type Options struct { DefaultConfigPath string } -// RegisterOption customizes one [Binder.Register] call. None exist yet; the parameter lets options be added without -// breaking callers. +// RegisterOption customizes one [Binder.Register] call, such as a [Profile]. type RegisterOption[T any] struct { setup func(*typedEntry[T]) (func(), error) } diff --git a/x/config/cli/profile.go b/x/config/cli/profile.go new file mode 100644 index 0000000000..b908e20169 --- /dev/null +++ b/x/config/cli/profile.go @@ -0,0 +1,388 @@ +package cli + +import ( + "errors" + "fmt" + "maps" + "reflect" + "slices" + "strings" + + "github.com/huandu/go-clone" + "github.com/spf13/cobra" +) + +// Profile builds the registered struct with the function its key's value picks. The key must be a top-level field of +// T bound as a flag, such as &c.ID; to profile a section, register it on its own with [Binder.RegisterInNamespace], or +// use [ProfileWithSelector]. The function gets T's defaults, as they are at Register, with the key already set, and +// changes what it needs, zeros included. A flag, env var, or config file value still takes priority: +// +// b.RegisterInNamespace(root, "Chain", &chain, cli.Profile( +// func(c *ChainConfig) *uint64 { return &c.ID }, +// map[uint64]func(*ChainConfig){ +// 137: func(c *ChainConfig) { c.RPC = "https://polygon.example.com"; c.Finality = 0 }, +// })) +func Profile[T any, K comparable](key func(*T) *K, profiles map[K]func(*T)) RegisterOption[T] { + return ProfileWithSelector(func(t *T) *T { return t }, key, profiles) +} + +// ProfileWithSelector is [Profile] for the section of T that section returns. The key must be a top-level field of +// that section. For a pointer section, S is the pointer, so its function gets a pointer to it, such as **ChainConfig; +// the section holds the key, so it is allocated again if the function leaves it nil. A key under a nil section picks +// no profile. Register fails if another profile's section holds this one or is held by it, as each profile rebuilds +// its whole section and two would drop each other's values: +// +// b.Register(root, &cfg, cli.ProfileWithSelector( +// func(c *Config) *ChainConfig { return &c.Chain }, +// func(c *ChainConfig) *uint64 { return &c.ID }, +// map[uint64]func(*ChainConfig){ +// 137: func(c *ChainConfig) { c.RPC = "https://polygon.example.com"; c.Finality = 0 }, +// })) +func ProfileWithSelector[T, S any, K comparable]( + section func(*T) *S, key func(*S) *K, profiles map[K]func(*S), +) RegisterOption[T] { + return RegisterOption[T]{setup: func(entry *typedEntry[T]) (func(), error) { + selector, err := locateProfile(entry, section, key) + if err != nil { + return nil, err + } + + scope := selector.goPath[:len(selector.goPath)-1] + for _, other := range entry.profileSelectors { + if scopesOverlap(other.goPath[:len(other.goPath)-1], scope) { + return nil, fmt.Errorf("profiles for %s and %s overlap: each rebuilds its whole section, so one would drop the other's values", other.key, selector.key) + } + } + + entry.profileSelectors = append(entry.profileSelectors, selector) + + // Snapshot once, like flag defaults, so every Execute starts from the defaults as they were at Register. + defaults := snapshotSection[S](entry, scope) + // Copied, so a change to the caller's map can't skip the checks. + profiles = maps.Clone(profiles) + if err := checkProfileKeys(entry, selector, scope, defaults, profiles); err != nil { + return nil, err + } + + return func() { + entry.profiles = append(entry.profiles, func(supplied map[string]sourceLayer) error { + return applyProfile(entry, supplied, selector, scope, clone.Slowly(defaults).(S), profiles) + }) + help := entry.cmd.HelpFunc() + entry.cmd.SetHelpFunc(func(c *cobra.Command, args []string) { + help(c, args) + _, _ = fmt.Fprintf(c.OutOrStdout(), "\nAVAILABLE PROFILES FOR --%s:\n%s", selector.flagName, + describeProfiles(entry, selector, scope, defaults, profiles)) + }) + }, nil + }} +} + +// An env var names a map key in upper case and matches an existing key ignoring case, so two keys in one map that +// differ only in case would make it ambiguous. A profile's own keys are checkable at Register; a config file's only +// at runtime. +func checkProfileKeys[T any, S any, K comparable]( + entry *typedEntry[T], selector leafKey, scope []string, defaults S, profiles map[K]func(*S), +) error { + for k, build := range profiles { + if build == nil { + return fmt.Errorf("profile %v has no function", k) + } + + // NaN never equals itself, so no lookup could select its profile. + if vk := reflect.ValueOf(k); !vk.Equal(vk) { + return fmt.Errorf("profile %v never equals itself, as NaN doesn't, so it can't be selected", k) + } + + built := clone.Slowly(defaults).(S) + vBuilt := reflect.ValueOf(&built).Elem() + allocateField(vBuilt, selector.goPath[len(scope):]).Set(reflect.ValueOf(k)) + build(&built) + + for _, leaf := range entry.leaves { + if !inScope(leaf, scope) { + continue + } + + m := readField(vBuilt, leaf.goPath[len(scope):]) + for _, el := range leaf.entries { + err := visitEntries(el, leaf.key, m, 0, func(name string, v reflect.Value, isMap bool) error { + if !isMap { + return nil + } + + return foldCollision(k, name, v) + }) + if err != nil { + return err + } + } + } + } + + return nil +} + +// Keys are sorted first, so the pair reported is deterministic. +func foldCollision(profile any, key string, m reflect.Value) error { + texts := make([]string, 0, m.Len()) + for k := range m.Seq() { + texts = append(texts, textOf(k)) + } + + slices.Sort(texts) + for i, text := range texts { + for _, other := range texts[i+1:] { + if strings.EqualFold(text, other) { + return fmt.Errorf("profile %v gives %s the keys %q and %q, which differ only in case, so an env var for either is ambiguous", profile, key, text, other) + } + } + } + + return nil +} + +// visitEntries calls visit for m and each map below it on leaf's path, then for each value leaf sets, named as their +// flags would be, such as section.chains.mainnet.labels. +func visitEntries(leaf *entryLeaf, name string, m reflect.Value, depth int, visit func(name string, v reflect.Value, isMap bool) error) error { + if err := visit(name, m, true); err != nil { + return err + } + + parts := strings.Split(leaf.key, "."+cliKeyPlaceholder) + byText := func(a, b reflect.Value) int { return strings.Compare(textOf(a), textOf(b)) } + for _, key := range slices.SortedFunc(slices.Values(m.MapKeys()), byText) { + inner := name + "." + textOf(key) + parts[depth+1] + v := readField(m.MapIndex(key), leaf.paths[depth]) + var err error + if depth == len(leaf.keyTypes)-1 { + err = visit(inner, v, false) + } else { + err = visitEntries(leaf, inner, v, depth+1, visit) + } + + if err != nil { + return err + } + } + + return nil +} + +// A required field left zero is omitted from help, as a source must set it. +func describeProfiles[T any, K comparable, S any]( + entry *typedEntry[T], selector leafKey, scope []string, defaults S, profiles map[K]func(*S), +) string { + byText := func(a, b K) int { return strings.Compare(fmt.Sprint(a), fmt.Sprint(b)) } + var out strings.Builder + for _, k := range slices.SortedFunc(maps.Keys(profiles), byText) { + _, _ = fmt.Fprintf(&out, " %v:\n", k) + // A fresh copy each time, so one profile's changes don't carry into the next. + built := clone.Slowly(defaults).(S) + vBuilt := reflect.ValueOf(&built).Elem() + allocateField(vBuilt, selector.goPath[len(scope):]).Set(reflect.ValueOf(k)) + profiles[k](&built) + + for _, leaf := range entry.leaves { + if !inScope(leaf, scope) || slices.Equal(leaf.goPath, selector.goPath) { + continue + } + + v := lookupField(vBuilt, leaf.goPath[len(scope):]) + if !v.IsValid() || (leaf.required && v.IsZero()) { + continue + } + + if text, ok := displayText(v); ok || len(leaf.entries) == 0 { + _, _ = fmt.Fprintf(&out, " %s = %s\n", leaf.key, text) + continue + } + + // A map of structs has no text of its own, so each value inside it is shown as its flag would set it. + for _, el := range leaf.entries { + if el.each != nil { + continue + } + + _ = visitEntries(el, leaf.key, derefOrZero(v), 0, func(name string, v reflect.Value, isMap bool) error { + if text, ok := displayText(v); ok && !isMap { + _, _ = fmt.Fprintf(&out, " %s = %s\n", name, text) + } + + return nil + }) + } + } + } + + return out.String() +} + +func locateProfile[T, S any, K comparable]( + entry *typedEntry[T], section func(*T) *S, key func(*S) *K, +) (selector leafKey, err error) { + // Allocated, so the functions can reach through pointers. + scratch := new(T) + v := reflect.ValueOf(scratch).Elem() + for _, k := range entry.leaves { + allocateField(v, k.goPath) + } + + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("profile selector panicked: %v", r) + } + }() + + if holdsIdentityKind(reflect.TypeFor[K]()) { + return leafKey{}, fmt.Errorf("profile key type %s compares by identity, so no value a source sets would match a profile", reflect.TypeFor[K]()) + } + + s := section(scratch) + wantKey := reflect.ValueOf(key(s)).Pointer() + wantSection := reflect.ValueOf(s).Pointer() + for _, k := range entry.leaves { + parent := lookupField(v, k.goPath[:len(k.goPath)-1]) + // Types are checked as a struct shares its first field's address. + if parent.Type() != reflect.TypeFor[S]() || parent.Addr().Pointer() != wantSection { + continue + } + + if k.flag != nil && k.goType == reflect.TypeFor[K]() && + lookupField(parent, k.goPath[len(k.goPath)-1:]).Addr().Pointer() == wantKey { + return k, nil + } + } + + return leafKey{}, errors.New("profile key must return a top-level field of its section bound as a flag, such as &c.ID") +} + +func inScope(k leafKey, scope []string) bool { + return len(k.goPath) > len(scope) && slices.Equal(k.goPath[:len(scope)], scope) +} + +func scopesOverlap(a, b []string) bool { + if len(a) > len(b) { + a, b = b, a + } + + return slices.Equal(a, b[:len(a)]) +} + +// Decoding overwrites the section, so its defaults are copied first, deeply. Each use copies them again, so that a +// profile changing a map or a pointer in its copy leaves them alone. +func snapshotSection[S, T any](entry *typedEntry[T], scope []string) S { + v := lookupField(reflect.ValueOf(entry.dst).Elem(), scope) + if !v.IsValid() { + var zero S + return zero + } + + return clone.Slowly(v.Interface()).(S) +} + +func applyProfile[T any, K comparable, S any]( + entry *typedEntry[T], supplied map[string]sourceLayer, selector leafKey, scope []string, base S, profiles map[K]func(*S), +) error { + vTarget := reflect.ValueOf(entry.dst).Elem() + // A section under a nil pointer that no source reached stays nil. + if !lookupField(vTarget, scope).IsValid() && + !slices.ContainsFunc(entry.leaves, func(k leafKey) bool { _, set := supplied[k.key]; return set && inScope(k, scope) }) { + return nil + } + + vBase := reflect.ValueOf(&base).Elem() + keyPath := selector.goPath[len(scope):] + // Prevent an earlier Execute from impacting the target. + selected := lookupField(vBase, keyPath) + if _, set := supplied[selector.key]; set { + selected = lookupField(vTarget, selector.goPath) + } + + // A key under a nil section picks no profile, but the sources still apply. + var key *K + if selected.IsValid() { + k := selected.Interface().(K) + key = &k + allocateField(vBase, keyPath).Set(reflect.ValueOf(k)) + if build, exists := profiles[k]; exists { + build(&base) + } + } + + for _, k := range entry.leaves { + layer, set := supplied[k.key] + if !inScope(k, scope) || !set { + continue + } + + src := lookupField(vTarget, k.goPath) + if !src.IsValid() { + continue + } + + dst := allocateField(vBase, k.goPath[len(scope):]) + if !dst.IsValid() { + continue + } + + if layer == nil { + dst.Set(src) + continue + } + + for dst.Kind() == reflect.Pointer { + if dst.IsNil() { + dst.Set(reflect.New(dst.Type().Elem())) + } + + dst = dst.Elem() + } + + v, err := layer(dst) + if err != nil { + return fmt.Errorf("%s: %w", k.key, err) + } + + dst.Set(v) + } + + if key != nil { + allocateField(vBase, keyPath).Set(reflect.ValueOf(*key)) + } + + allocateField(vTarget, scope).Set(vBase) + return nil +} + +// In the form the field's flag takes. +func displayText(v reflect.Value) (string, bool) { + v = derefOrZero(v) + switch t := v.Type(); { + case canText(t): + return textOf(v), true + case t.Kind() == reflect.Slice && t.Elem().Kind() != reflect.Pointer && canText(t.Elem()): + return writeCSV(textListOf(v)), true + case t.Kind() == reflect.Map && t.Elem().Kind() != reflect.Pointer && canText(t.Key()) && canText(t.Elem()): + return writeCSV(textMapOf(v)), true + } + + return cannotDisplay, false +} + +// Unlike allocateField, it never allocates, so reading a key or a section leaves nil sections nil. +func lookupField(v reflect.Value, path []string) reflect.Value { + for _, p := range path { + for v.Kind() == reflect.Pointer { + if v.IsNil() { + return reflect.Value{} + } + + v = v.Elem() + } + + v = v.FieldByName(p) + } + + return v +} diff --git a/x/config/cli/profile_test.go b/x/config/cli/profile_test.go new file mode 100644 index 0000000000..3b252e536b --- /dev/null +++ b/x/config/cli/profile_test.go @@ -0,0 +1,432 @@ +package cli + +import ( + "math" + "os" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type profiledSection struct { + Selector uint32 + Profiled string + Count int +} + +func selector(s *profiledSection) *uint32 { return &s.Selector } + +var sectionProfiles = map[uint32]func(*profiledSection){ + 1: func(s *profiledSection) { s.Profiled = "one" }, + 137: func(s *profiledSection) { s.Profiled = "137"; s.Count = 0 }, +} + +func withProfile[T any, K comparable](t *testing.T, c *T, key func(*T) *K, profiles map[K]func(*T)) *cobra.Command { + t.Helper() + + root := newRoot(t) + require.NoError(t, newBinder(t, testOptions).RegisterInNamespace(root, "Section", c, Profile(key, profiles))) + return root +} + +func TestProfileSelection(t *testing.T) { + for _, tc := range []struct { + name, file string + section profiledSection + args []string + want profiledSection + }{ + {name: "a default selector picks its profile", section: profiledSection{Selector: 1}, + want: profiledSection{Selector: 1, Profiled: "one"}}, + {name: "a changed selector picks the new one", section: profiledSection{Selector: 1}, args: []string{"--section.selector", "137"}, + want: profiledSection{Selector: 137, Profiled: "137"}}, + {name: "it replaces a compiled-in default", section: profiledSection{Selector: 1, Profiled: "compiled"}, + want: profiledSection{Selector: 1, Profiled: "one"}}, + {name: "it can set a zero", section: profiledSection{Selector: 137, Count: 5}, + want: profiledSection{Selector: 137, Profiled: "137"}}, + {name: "it builds on the defaults", section: profiledSection{Selector: 1, Count: 5}, + want: profiledSection{Selector: 1, Profiled: "one", Count: 5}}, + {name: "a source beats it", section: profiledSection{Selector: 1}, file: "[Section]\nProfiled = 'file'", + want: profiledSection{Selector: 1, Profiled: "file"}}, + // Such as a chain that isn't built in. + {name: "a selector with no profile leaves the section to the sources", section: profiledSection{Selector: 1, Profiled: "compiled"}, + args: []string{"--section.selector", "999"}, want: profiledSection{Selector: 999, Profiled: "compiled"}}, + } { + t.Run(tc.name, func(t *testing.T) { + c := tc.section + root := withProfile(t, &c, selector, sectionProfiles) + root.SetArgs(supplyConfig(t, "", "", tc.file, tc.args...)) + require.NoError(t, root.Execute()) + assert.Equal(t, tc.want, c) + }) + } +} + +func TestProfileCannotSwitchItsSelector(t *testing.T) { + c := profiledSection{Selector: 1} + profiles := map[uint32]func(*profiledSection){1: func(s *profiledSection) { s.Selector = 137 }} + require.NoError(t, withProfile(t, &c, selector, profiles).Execute()) + assert.Equal(t, uint32(1), c.Selector) +} + +func TestProfileIsRejected(t *testing.T) { + type nestsProfiledSection struct { + Section profiledSection + List []profiledSection + } + var elsewhere uint32 + none := map[uint32]func(*nestsProfiledSection){} + want := "profile key must return a top-level field of its section bound as a flag" + + for _, key := range []func(*nestsProfiledSection) *uint32{ + func(*nestsProfiledSection) *uint32 { return &elsewhere }, + func(*nestsProfiledSection) *uint32 { return nil }, + func(c *nestsProfiledSection) *uint32 { return &c.Section.Selector }, + } { + require.ErrorContains(t, newBinder(t, testOptions).Register(newRoot(t), &nestsProfiledSection{}, Profile(key, none)), want) + } + + type hasArraySelector struct{ Selector [2]int } + require.ErrorContains(t, newBinder(t, testOptions).Register(newRoot(t), &hasArraySelector{}, + Profile(func(c *hasArraySelector) *[2]int { return &c.Selector }, map[[2]int]func(*hasArraySelector){})), want) + + type hasPointerSelector struct{ Selector *uint32 } + require.ErrorContains(t, newBinder(t, testOptions).Register(newRoot(t), &hasPointerSelector{}, + Profile(func(c *hasPointerSelector) **uint32 { return &c.Selector }, map[*uint32]func(*hasPointerSelector){})), + "profile key type *uint32 compares by identity, so no value a source sets would match a profile") + + require.ErrorContains(t, newBinder(t, testOptions).Register(newRoot(t), &nestsProfiledSection{}, ProfileWithSelector( + func(c *nestsProfiledSection) *profiledSection { return &c.List[0] }, selector, sectionProfiles)), + "profile selector panicked: runtime error: index out of range") +} + +func TestOverlappingProfilesAreRejected(t *testing.T) { + type twoSelectors struct { + Selector uint32 + Selector2 uint32 + } + require.ErrorContains(t, newBinder(t, testOptions).Register(newRoot(t), &twoSelectors{}, + Profile(func(c *twoSelectors) *uint32 { return &c.Selector }, map[uint32]func(*twoSelectors){}), + Profile(func(c *twoSelectors) *uint32 { return &c.Selector2 }, map[uint32]func(*twoSelectors){})), + "profiles for selector and selector-2 overlap: each rebuilds its whole section, so one would drop the other's values") + + type nestsProfiled struct { + Selector uint32 + Section profiledSection + } + require.ErrorContains(t, newBinder(t, testOptions).Register(newRoot(t), &nestsProfiled{}, + Profile(func(c *nestsProfiled) *uint32 { return &c.Selector }, map[uint32]func(*nestsProfiled){}), + ProfileWithSelector(func(c *nestsProfiled) *profiledSection { return &c.Section }, selector, sectionProfiles)), + "profiles for selector and section.selector overlap") +} + +func TestProfileHelpListsWhatEachProfileSets(t *testing.T) { + type hasRequired struct { + Selector uint32 + Profiled string + Count int + Needed string `validate:"required"` + Kept string `validate:"required"` + } + profiles := map[uint32]func(*hasRequired){ + 10: func(s *hasRequired) { s.Profiled = "ten"; s.Needed = "given" }, + 1: func(s *hasRequired) { s.Profiled = "" }, + } + + c := hasRequired{Profiled: "default", Count: 5, Kept: "default"} + root := withProfile(t, &c, func(s *hasRequired) *uint32 { return &s.Selector }, profiles) + var out strings.Builder + root.SetOut(&out) + root.SetArgs([]string{"--help"}) + require.NoError(t, root.Execute()) + want := "AVAILABLE PROFILES FOR --section.selector:\n" + + " 1:\n section.profiled = \n section.count = 5\n section.kept = default\n" + + " 10:\n section.profiled = ten\n section.count = 5\n section.needed = given\n section.kept = default\n" + assert.True(t, strings.HasSuffix(out.String(), want), out.String()) +} + +func TestProfileOwnsAPointerSection(t *testing.T) { + type hasValue struct { + Value int + } + type hasPointerSection struct { + Selector string + Section *hasValue + } + profiles := map[string]func(*hasPointerSection){ + "allocates": func(c *hasPointerSection) { c.Section = &hasValue{Value: 10} }, + "leaves": func(*hasPointerSection) {}, + } + selector := func(c *hasPointerSection) *string { return &c.Selector } + + allocated := hasPointerSection{Selector: "allocates"} + require.NoError(t, withProfile(t, &allocated, selector, profiles).Execute()) + assert.Equal(t, hasPointerSection{"allocates", &hasValue{10}}, allocated) + + left := hasPointerSection{Selector: "leaves"} + require.NoError(t, withProfile(t, &left, selector, profiles).Execute()) + assert.Equal(t, hasPointerSection{Selector: "leaves"}, left) +} + +func TestProfileHelpOmitsANilSection(t *testing.T) { + type hasValue struct { + Value int + } + type hasPointerSection struct { + Selector string + Section *hasValue + } + profiles := map[string]func(*hasPointerSection){"nils": func(c *hasPointerSection) { c.Section = nil }} + + c := hasPointerSection{Section: &hasValue{Value: 1}} + root := withProfile(t, &c, func(c *hasPointerSection) *string { return &c.Selector }, profiles) + var out strings.Builder + root.SetOut(&out) + root.SetArgs([]string{"--help"}) + require.NoError(t, root.Execute()) + assert.True(t, strings.HasSuffix(out.String(), "AVAILABLE PROFILES FOR --section.selector:\n nils:\n"), out.String()) +} + +func TestTwoProfilesOnOneCommand(t *testing.T) { + type profiledByString struct { + Selector string + Profiled string + } + + root := newRoot(t) + b := newBinder(t, testOptions) + section, other := profiledSection{Selector: 1}, profiledByString{Selector: "s"} + require.NoError(t, b.RegisterInNamespace(root, "Section", §ion, Profile(selector, sectionProfiles))) + require.NoError(t, b.RegisterInNamespace(root, "Other", &other, Profile(func(o *profiledByString) *string { return &o.Selector }, + map[string]func(*profiledByString){"s": func(o *profiledByString) { o.Profiled = "other" }}))) + require.NoError(t, root.Execute()) + assert.Equal(t, profiledSection{1, "one", 0}, section) + assert.Equal(t, profiledByString{"s", "other"}, other) +} + +func TestProfileMapEntriesLayerUnderSources(t *testing.T) { + type hasMap struct { + Selector uint32 + Labels map[string]string + } + profiles := map[uint32]func(*hasMap){1: func(c *hasMap) { c.Labels = map[string]string{"a": "profile", "b": "profile"} }} + + for _, args := range [][]string{{"--section.labels.a", "flag"}, {"--section.labels", "a=flag"}} { + c := hasMap{Selector: 1, Labels: map[string]string{"c": "default"}} + root := withProfile(t, &c, func(c *hasMap) *uint32 { return &c.Selector }, profiles) + root.SetArgs(args) + require.NoError(t, root.Execute()) + assert.Equal(t, map[string]string{"a": "flag", "b": "profile"}, c.Labels) + } +} + +func TestProfileMapEntryEnvVarMatchesAProfileKey(t *testing.T) { + type hasMap struct { + Selector uint32 + Labels map[string]string + } + profiles := map[uint32]func(*hasMap){1: func(c *hasMap) { c.Labels = map[string]string{"Mainnet": "profile"} }} + + c := hasMap{Selector: 1, Labels: map[string]string{"mainnet": "a", "MainNet": "b"}} + root := withProfile(t, &c, func(c *hasMap) *uint32 { return &c.Selector }, profiles) + root.SetArgs(supplyConfig(t, "TEST_SECTION_LABELS_MAINNET", "env", "")) + require.NoError(t, root.Execute()) + assert.Equal(t, map[string]string{"Mainnet": "env"}, c.Labels) +} + +func TestProfileMapKeysCollidingInCaseAreRejected(t *testing.T) { + type hasMap struct { + Selector uint32 + Labels map[string]string + } + profiles := map[uint32]func(*hasMap){ + 1: func(c *hasMap) { c.Labels = map[string]string{"MAINNET": "a", "Mainnet": "b"} }, + } + + c := hasMap{Selector: 1} + require.ErrorContains(t, + newBinder(t, testOptions).RegisterInNamespace(newRoot(t), "Section", &c, + Profile(func(c *hasMap) *uint32 { return &c.Selector }, profiles)), + `profile 1 gives section.labels the keys "MAINNET" and "Mainnet", which differ only in case, so an env var for either is ambiguous`) +} + +func TestEachExecuteStartsFromTheDefaults(t *testing.T) { + c := profiledSection{Selector: 1, Profiled: "compiled"} + root := withProfile(t, &c, selector, sectionProfiles) + require.NoError(t, root.Execute()) + assert.Equal(t, "one", c.Profiled) + + root.SetArgs([]string{"--section.selector", "999"}) + require.NoError(t, root.Execute()) + assert.Equal(t, profiledSection{Selector: 999, Profiled: "compiled"}, c) +} + +func TestUnsuppliedSelectorReturnsToItsDefault(t *testing.T) { + c := profiledSection{Selector: 1} + root := withProfile(t, &c, selector, sectionProfiles) + t.Setenv("TEST_SECTION_SELECTOR", "137") + require.NoError(t, root.Execute()) + assert.Equal(t, profiledSection{Selector: 137, Profiled: "137"}, c) + + require.NoError(t, os.Unsetenv("TEST_SECTION_SELECTOR")) + require.NoError(t, root.Execute()) + assert.Equal(t, profiledSection{Selector: 1, Profiled: "one"}, c) +} + +func TestTwoNestedProfilesScopeToTheirOwnSection(t *testing.T) { + type profiledByString struct { + Selector string + Profiled string + } + type nestsTwoProfiled struct { + Section profiledSection + Other profiledByString + } + + root := newRoot(t) + c := nestsTwoProfiled{Section: profiledSection{Selector: 1}, Other: profiledByString{Selector: "s"}} + require.NoError(t, newBinder(t, testOptions).Register(root, &c, + ProfileWithSelector(func(c *nestsTwoProfiled) *profiledSection { return &c.Section }, selector, sectionProfiles), + ProfileWithSelector(func(c *nestsTwoProfiled) *profiledByString { return &c.Other }, + func(o *profiledByString) *string { return &o.Selector }, + map[string]func(*profiledByString){"s": func(o *profiledByString) { o.Profiled = "other" }}), + )) + root.SetArgs([]string{"--section.selector", "137"}) + require.NoError(t, root.Execute()) + assert.Equal(t, nestsTwoProfiled{profiledSection{137, "137", 0}, profiledByString{"s", "other"}}, c) +} + +func TestNestedProfileMayReachThroughAPointerSection(t *testing.T) { + type nestsProfiledSectionPointer struct { + Section *profiledSection + } + profiles := map[uint32]func(**profiledSection){1: func(s **profiledSection) { (*s).Profiled = "one" }} + register := func(c *nestsProfiledSectionPointer) *cobra.Command { + root := newRoot(t) + require.NoError(t, newBinder(t, testOptions).Register(root, c, ProfileWithSelector( + func(c *nestsProfiledSectionPointer) **profiledSection { return &c.Section }, + func(s **profiledSection) *uint32 { return &(*s).Selector }, profiles))) + return root + } + + var c nestsProfiledSectionPointer + root := register(&c) + root.SetArgs([]string{"--section.selector", "1"}) + require.NoError(t, root.Execute()) + assert.Equal(t, nestsProfiledSectionPointer{&profiledSection{Selector: 1, Profiled: "one"}}, c) + + var unset nestsProfiledSectionPointer + require.NoError(t, register(&unset).Execute()) + assert.Nil(t, unset.Section) +} + +func TestNestedProfileUnderANilPointerDoesNothing(t *testing.T) { + type nestsProfiledSection struct { + Section profiledSection + } + type nestsUnderAPointer struct { + Outer *nestsProfiledSection + } + + var c nestsUnderAPointer + root := newRoot(t) + require.NoError(t, newBinder(t, testOptions).Register(root, &c, ProfileWithSelector( + func(c *nestsUnderAPointer) *profiledSection { return &c.Outer.Section }, selector, sectionProfiles))) + require.NoError(t, root.Execute()) + assert.Nil(t, c.Outer) +} + +func TestProfileKeyNaNIsRejected(t *testing.T) { + type hasFloat struct { + Selector float64 + } + + c := hasFloat{} + require.ErrorContains(t, + newBinder(t, testOptions).RegisterInNamespace(newRoot(t), "Section", &c, + Profile(func(c *hasFloat) *float64 { return &c.Selector }, map[float64]func(*hasFloat){math.NaN(): func(*hasFloat) {}})), + "profile NaN never equals itself, as NaN doesn't, so it can't be selected") +} + +func TestProfileNestedMapKeysCollidingInCaseAreRejected(t *testing.T) { + type chain struct { + Labels map[string]string + } + type hasChains struct { + Selector uint32 + Chains map[string]chain + } + profiles := map[uint32]func(*hasChains){ + 1: func(c *hasChains) { + c.Chains = map[string]chain{"a": {Labels: map[string]string{"Σ": "x", "ς": "y"}}} + }, + } + + c := hasChains{} + require.ErrorContains(t, + newBinder(t, testOptions).RegisterInNamespace(newRoot(t), "Section", &c, + Profile(func(c *hasChains) *uint32 { return &c.Selector }, profiles)), + `profile 1 gives section.chains.a.labels the keys "Σ" and "ς", which differ only in case`) +} + +func TestProfileHelpShowsCollections(t *testing.T) { + type chain struct { + RPC string + } + type hasCollections struct { + Selector uint32 + List []string + Labels map[string]string + Chains map[string]chain + } + profiles := map[uint32]func(*hasCollections){ + 1: func(c *hasCollections) { + c.List = []string{"a", "b"} + c.Labels = map[string]string{"k": "v"} + c.Chains = map[string]chain{"main": {RPC: "url"}} + }, + } + + c := hasCollections{} + root := withProfile(t, &c, func(c *hasCollections) *uint32 { return &c.Selector }, profiles) + var out strings.Builder + root.SetOut(&out) + root.SetArgs([]string{"--help"}) + require.NoError(t, root.Execute()) + want := "AVAILABLE PROFILES FOR --section.selector:\n" + + " 1:\n section.list = a,b\n section.labels = k=v\n section.chains.main.rpc = url\n" + assert.True(t, strings.HasSuffix(out.String(), want), out.String()) +} + +func TestProfileWithoutAFunctionIsRejected(t *testing.T) { + c := profiledSection{} + require.ErrorContains(t, + newBinder(t, testOptions).RegisterInNamespace(newRoot(t), "Section", &c, + Profile(selector, map[uint32]func(*profiledSection){7: nil})), + "profile 7 has no function") +} + +func TestProfileRegistersACyclicDefault(t *testing.T) { + type cyclic struct { + Selector uint32 + Value int + Self *cyclic + } + + c := cyclic{Selector: 1} + c.Self = &c + withProfile(t, &c, func(c *cyclic) *uint32 { return &c.Selector }, map[uint32]func(*cyclic){1: func(c *cyclic) { c.Value = 1 }}) +} + +func TestProfilesChangedAfterRegisterAreIgnored(t *testing.T) { + profiles := map[uint32]func(*profiledSection){1: func(s *profiledSection) { s.Profiled = "one" }} + c := profiledSection{Selector: 1} + root := withProfile(t, &c, selector, profiles) + profiles[1] = nil + + require.NoError(t, root.Execute()) + assert.Equal(t, profiledSection{Selector: 1, Profiled: "one"}, c) +} diff --git a/x/config/cli/register.go b/x/config/cli/register.go index 485c440779..548436e8a3 100644 --- a/x/config/cli/register.go +++ b/x/config/cli/register.go @@ -30,6 +30,7 @@ func bindLeafFlag(entry targetEntry, m fieldMeta, doc string) (leafKey, error) { goPath: m.goPath, goType: m.field.Type, fileType: fileType, + required: isRequired(m.field), } // pflag panics on a redefinition. if flags.Lookup(leaf.flagName) != nil { @@ -38,7 +39,7 @@ func bindLeafFlag(entry targetEntry, m fieldMeta, doc string) (leafKey, error) { usage := doc // Under a pointer section the rule applies only once the section is configured. - if isRequired(m.field) && !m.inOptional { + if leaf.required && !m.inOptional { usage = strings.TrimSpace(usage + " (required)") } diff --git a/x/config/go.mod b/x/config/go.mod index 4387a507d5..7b0e55c418 100644 --- a/x/config/go.mod +++ b/x/config/go.mod @@ -4,6 +4,7 @@ go 1.27.1 require ( github.com/go-playground/validator/v10 v10.26.0 + github.com/huandu/go-clone v1.7.3 github.com/iancoleman/strcase v0.3.0 github.com/pelletier/go-toml/v2 v2.4.3 github.com/smartcontractkit/chainlink-common v0.9.6-0.20260206011444-ed1fb0284e5d diff --git a/x/config/go.sum b/x/config/go.sum index 92ca02b542..45b2c97779 100644 --- a/x/config/go.sum +++ b/x/config/go.sum @@ -1,4 +1,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= @@ -11,6 +15,10 @@ github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/huandu/go-assert v1.1.5 h1:fjemmA7sSfYHJD7CUqs9qTwwfdNAx7/j2/ZlHXzNB3c= +github.com/huandu/go-assert v1.1.5/go.mod h1:yOLvuqZwmcHIC5rIzrBhT7D3Q9c3GFnd0JrPVhn/06U= +github.com/huandu/go-clone v1.7.3 h1:rtQODA+ABThEn6J5LBTppJfKmZy/FwfpMUWa8d01TTQ= +github.com/huandu/go-clone v1.7.3/go.mod h1:ReGivhG6op3GYr+UY3lS6mxjKp7MIGTknuU5TbTVaXE= github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -19,6 +27,7 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= @@ -29,6 +38,8 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= @@ -51,3 +62,4 @@ golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=