diff --git a/go.mod b/go.mod index 64b610c6d2..05b9ec8abd 100644 --- a/go.mod +++ b/go.mod @@ -56,6 +56,8 @@ require ( github.com/smartcontractkit/grpc-proxy v0.0.0-20240830132753-a7e17fec5ab7 github.com/smartcontractkit/libocr v0.0.0-20250912173940-f3ab0246e23d github.com/spf13/cobra v1.8.1 + github.com/spf13/pflag v1.0.10 + github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 go.opentelemetry.io/contrib/bridges/prometheus v0.68.0 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 @@ -101,6 +103,7 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudevents/sdk-go/v2 v2.16.1 // indirect github.com/fatih/color v1.18.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -143,10 +146,14 @@ require ( github.com/prometheus/procfs v0.20.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/ryanuber/go-glob v1.0.0 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sanity-io/litter v1.5.5 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect - github.com/spf13/pflag v1.0.5 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect github.com/stretchr/objx v0.5.2 // indirect + github.com/subosito/gotenv v1.6.0 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect @@ -155,6 +162,7 @@ require ( go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/mod v0.36.0 // indirect golang.org/x/net v0.55.0 // indirect golang.org/x/sys v0.45.0 // indirect diff --git a/go.sum b/go.sum index 473214a72e..916230c125 100644 --- a/go.sum +++ b/go.sum @@ -59,6 +59,10 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7 github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= @@ -264,6 +268,8 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/sanity-io/litter v1.5.5 h1:iE+sBxPBzoK6uaEP5Lt3fHNgpKcHXc/A2HGETy0uJQo= github.com/sanity-io/litter v1.5.5/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U= github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= @@ -298,10 +304,19 @@ github.com/smartcontractkit/grpc-proxy v0.0.0-20240830132753-a7e17fec5ab7 h1:12i github.com/smartcontractkit/grpc-proxy v0.0.0-20240830132753-a7e17fec5ab7/go.mod h1:FX7/bVdoep147QQhsOPkYsPEXhGZjeYx6lBSaSXtZOA= github.com/smartcontractkit/libocr v0.0.0-20250912173940-f3ab0246e23d h1:LokA9PoCNb8mm8mDT52c3RECPMRsGz1eCQORq+J3n74= github.com/smartcontractkit/libocr v0.0.0-20250912173940-f3ab0246e23d/go.mod h1:Acy3BTBxou83ooMESLO90s8PKSu7RvLCzwSTbxxfOK0= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/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/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -317,6 +332,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= @@ -386,6 +403,8 @@ go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= diff --git a/pkg/config/configdoc/configdoc.go b/pkg/config/configdoc/configdoc.go index c4e6ab6b49..a9a8009566 100644 --- a/pkg/config/configdoc/configdoc.go +++ b/pkg/config/configdoc/configdoc.go @@ -11,17 +11,28 @@ import ( const ( FieldDefault = "# Default" FieldExample = "# Example" + // FieldDocsOnly marks a field that is documented but left out of every example - both the + // document's example config and the code block of the table it belongs to. Use it for a + // setting that only applies in a mode the examples do not show, so that a reader can copy + // any example verbatim and get a configuration that works. + FieldDocsOnly = "# Docs only" TokenAdvanced = "**ADVANCED**" ) // Generate returns MarkDown documentation generated from the TOML string. -// - Each field but include a trailing comment of either FieldDefault or FieldExample. +// - Each field but include a trailing comment of FieldDefault, FieldExample or FieldDocsOnly. // - If a description begins with TokenAdvanced, then a warning will be included. // - The markdown wil begin with the header, followed by the example // - Extended descriptions can be applied to top level tables func Generate(toml, header, example string, extendedDescriptions map[string]string) (string, error) { - items, err := parseTOMLDocs(toml, extendedDescriptions) + return GenerateWith(TOML{}, toml, header, example, extendedDescriptions) +} + +// GenerateWith is Generate for a document written in the syntax described by format. Generate +// is GenerateWith(TOML{}, ...). +func GenerateWith(format Format, doc, header, example string, extendedDescriptions map[string]string) (string, error) { + items, err := parseDocs(format, doc, extendedDescriptions) var sb strings.Builder sb.WriteString(header) @@ -29,7 +40,9 @@ func Generate(toml, header, example string, extendedDescriptions map[string]stri ## Example `) - sb.WriteString("```toml\n") + sb.WriteString("```") + sb.WriteString(format.Name()) + sb.WriteString("\n") sb.WriteString(example) sb.WriteString("\n```\n\n") @@ -53,6 +66,7 @@ func (d lines) String() string { } type table struct { + lang string name string codes lines adv bool @@ -60,9 +74,10 @@ type table struct { extended string } -func newTable(line string, desc lines, extendedDescriptions map[string]string) *table { +func newTable(lang, line, name string, desc lines, extendedDescriptions map[string]string) *table { t := &table{ - name: strings.Trim(line, "[]"), + lang: lang, + name: name, codes: []string{line}, desc: desc, } @@ -78,9 +93,10 @@ func newTable(line string, desc lines, extendedDescriptions map[string]string) * return t } -func newArrayOfTables(line string, desc lines, extendedDescriptions map[string]string) *table { +func newArrayOfTables(lang, line, name string, desc lines, extendedDescriptions map[string]string) *table { t := &table{ - name: strings.Trim(strings.Trim(line, FieldExample), "[]"), + lang: lang, + name: name, codes: []string{line}, desc: desc, } @@ -105,7 +121,7 @@ func (t table) advanced() string { func (t table) code() string { if t.extended == "" { - return fmt.Sprint("```toml\n", t.codes, "\n```\n") + return fmt.Sprint("```", t.lang, "\n", t.codes, "\n```\n") } return "" } @@ -120,16 +136,18 @@ func (t *table) String() string { } type keyval struct { + lang string name string code string adv bool desc lines } -func newKeyval(line string, desc lines) keyval { +func newKeyval(lang, line, name string, desc lines) keyval { line = strings.TrimSpace(line) kv := keyval{ - name: line[:strings.Index(line, " ")], + lang: lang, + name: name, code: line, desc: desc, } @@ -155,38 +173,39 @@ func (k keyval) String() string { } return fmt.Sprint("### ", name, "\n", k.advanced(), - "```toml\n", + "```", k.lang, "\n", k.code, "\n```\n", k.desc) } -func parseTOMLDocs(s string, extendedDescriptions map[string]string) (items []fmt.Stringer, err error) { +func parseDocs(format Format, s string, extendedDescriptions map[string]string) (items []fmt.Stringer, err error) { defer func() { _, err = config.MultiErrorList(err) }() - globalTable := table{name: "Global"} + globalTable := table{lang: format.Name(), name: "Global"} currentTable := &globalTable items = append(items, currentTable) var desc lines + defaultMarker, exampleMarker, docsOnlyMarker := format.DefaultMarker(), format.ExampleMarker(), format.DocsOnlyMarker() for line := range strings.SplitSeq(s, "\n") { - if strings.HasPrefix(line, "#") { - // comment - desc = append(desc, strings.TrimSpace(line[1:])) - } else if strings.TrimSpace(line) == "" { - // empty + parsed := format.ParseLine(line) + switch parsed.Kind { + case LineComment: + desc = append(desc, parsed.Text) + case LineBlank: if len(desc) > 0 { items = append(items, desc) desc = nil } - } else if strings.HasPrefix(line, "[[") { - currentTable = newArrayOfTables(line, desc, extendedDescriptions) + case LineArrayOfTables: + currentTable = newArrayOfTables(format.Name(), line, parsed.Text, desc, extendedDescriptions) items = append(items, currentTable) desc = nil - } else if strings.HasPrefix(line, "[") { - currentTable = newTable(line, desc, extendedDescriptions) + case LineTable: + currentTable = newTable(format.Name(), line, parsed.Text, desc, extendedDescriptions) items = append(items, currentTable) desc = nil - } else { - kv := newKeyval(line, desc) + default: + kv := newKeyval(format.Name(), line, parsed.Text, desc) shortName := kv.name if currentTable != &globalTable { // update to full name @@ -197,12 +216,17 @@ func parseTOMLDocs(s string, extendedDescriptions map[string]string) (items []fm } else if !strings.HasPrefix(kv.desc[0], shortName) { err = errors.Join(err, fmt.Errorf("%s: description does not begin with %q", kv.name, shortName)) } - if !strings.HasSuffix(line, FieldDefault) && !strings.HasSuffix(line, FieldExample) { - err = errors.Join(err, fmt.Errorf(`%s: is not one of %v`, kv.name, []string{FieldDefault, FieldExample})) + docsOnly := strings.HasSuffix(line, docsOnlyMarker) + if !docsOnly && !strings.HasSuffix(line, defaultMarker) && !strings.HasSuffix(line, exampleMarker) { + err = errors.Join(err, fmt.Errorf(`%s: is not one of %v`, kv.name, []string{defaultMarker, exampleMarker, docsOnlyMarker})) } items = append(items, kv) - currentTable.codes = append(currentTable.codes, kv.code) + // A docs-only field still gets its own entry, but is kept out of the table's code + // block so every example in the document agrees on what a working config contains. + if !docsOnly { + currentTable.codes = append(currentTable.codes, kv.code) + } desc = nil } } diff --git a/pkg/config/configdoc/format.go b/pkg/config/configdoc/format.go new file mode 100644 index 0000000000..0caa5273c9 --- /dev/null +++ b/pkg/config/configdoc/format.go @@ -0,0 +1,53 @@ +package configdoc + +// LineKind classifies a line of a configuration document. +type LineKind int + +const ( + // LineBlank is an empty or whitespace-only line, which terminates a comment block. + LineBlank LineKind = iota + // LineComment is a description line. + LineComment + // LineTable opens a table (section) of fields. + LineTable + // LineArrayOfTables opens a repeated table. + LineArrayOfTables + // LineField is a key/value pair. + LineField +) + +// Line is a parsed line: its kind, plus the piece of it that carries meaning - the comment +// text with its marker stripped, the table's name, or the field's key. +type Line struct { + Kind LineKind + Text string +} + +// Format is a configuration file syntax. It both writes the pieces of a document (so callers +// can assemble one from Go structs) and recognizes them when reading one back (so a +// hand-written document can be turned into documentation). Implement it to document a format +// other than TOML; see TOML for the reference implementation. +type Format interface { + // Comment renders text as a description line. + Comment(text string) string + // Table renders the opening of a section, given its dotted path (e.g. "chain.nodes"). + Table(path string) string + // Field renders a key/value line. marker is DefaultMarker, ExampleMarker or + // DocsOnlyMarker, or empty for a plain value line with no documentation annotation. + Field(key, value, marker string) string + // Literal renders a Go value as a value literal of this format. + Literal(v any) string + + // DefaultMarker annotates a field whose value is its real default. + DefaultMarker() string + // ExampleMarker annotates a field with no usable default, whose value is a placeholder. + ExampleMarker() string + // DocsOnlyMarker annotates a field that is documented but shown in no example. + DocsOnlyMarker() string + + // ParseLine classifies one line of a document. + ParseLine(line string) Line + + // Name returns the markdown name for code blocks + Name() string +} diff --git a/pkg/config/configdoc/toml.go b/pkg/config/configdoc/toml.go new file mode 100644 index 0000000000..146c044c80 --- /dev/null +++ b/pkg/config/configdoc/toml.go @@ -0,0 +1,105 @@ +package configdoc + +import ( + "encoding" + "fmt" + "reflect" + "strconv" + "strings" + "time" +) + +// TOML documents TOML configuration: `# comment`, `[table]`, `key = value # Default`. +type TOML struct{} + +func (t TOML) Name() string { + return "toml" +} + +var _ Format = TOML{} + +func (TOML) Comment(text string) string { return "# " + text } + +func (TOML) Table(path string) string { return "[" + path + "]" } + +func (TOML) Field(key, value, marker string) string { + if marker == "" { + return fmt.Sprintf("%s = %s", key, value) + } + return fmt.Sprintf("%s = %s %s", key, value, marker) +} + +func (TOML) DefaultMarker() string { return FieldDefault } + +func (TOML) ExampleMarker() string { return FieldExample } + +func (TOML) DocsOnlyMarker() string { return FieldDocsOnly } + +func (TOML) ParseLine(line string) Line { + switch { + case strings.HasPrefix(line, "#"): + return Line{Kind: LineComment, Text: strings.TrimSpace(line[1:])} + case strings.TrimSpace(line) == "": + return Line{Kind: LineBlank} + case strings.HasPrefix(line, "[["): + return Line{Kind: LineArrayOfTables, Text: strings.Trim(strings.Trim(line, FieldExample), "[]")} + case strings.HasPrefix(line, "["): + return Line{Kind: LineTable, Text: strings.Trim(line, "[]")} + default: + name := strings.TrimSpace(line) + if i := strings.Index(name, " "); i > -1 { + name = name[:i] + } + return Line{Kind: LineField, Text: name} + } +} + +var textMarshalerType = reflect.TypeOf((*encoding.TextMarshaler)(nil)).Elem() + +// Literal renders v as a TOML value literal. Values that marshal themselves to text (such as +// config.Duration) are written as their text form in quotes, so they round-trip through the +// same UnmarshalText that reads them back. +func (t TOML) Literal(val any) string { + if val == nil { + return "''" + } + + v := reflect.ValueOf(val) + if v.Kind() == reflect.Pointer { + if v.IsNil() { + return "''" + } + v = v.Elem() + } + + if v.CanInterface() && v.Type().Implements(textMarshalerType) { + if b, err := v.Interface().(encoding.TextMarshaler).MarshalText(); err == nil { + return "'" + string(b) + "'" + } + } + + if v.Type() == reflect.TypeOf(time.Duration(0)) { + return "'" + time.Duration(v.Int()).String() + "'" + } + + switch v.Kind() { + case reflect.String: + return "'" + v.String() + "'" + case reflect.Bool: + return strconv.FormatBool(v.Bool()) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return strconv.FormatInt(v.Int(), 10) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return strconv.FormatUint(v.Uint(), 10) + case reflect.Float32, reflect.Float64: + return strconv.FormatFloat(v.Float(), 'f', -1, 64) + case reflect.Slice, reflect.Array: + parts := make([]string, v.Len()) + for i := range parts { + parts[i] = t.Literal(v.Index(i).Interface()) + } + return "[" + strings.Join(parts, ", ") + "]" + default: + return fmt.Sprintf("'%v'", v.Interface()) + } +} diff --git a/pkg/config/configdoc/toml_test.go b/pkg/config/configdoc/toml_test.go new file mode 100644 index 0000000000..4618900da4 --- /dev/null +++ b/pkg/config/configdoc/toml_test.go @@ -0,0 +1,143 @@ +package configdoc + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-common/pkg/config" +) + +func TestTOMLName(t *testing.T) { + // Used as the markdown code fence language. + assert.Equal(t, "toml", TOML{}.Name()) +} + +func TestTOMLComment(t *testing.T) { + assert.Equal(t, "# the host", TOML{}.Comment("the host")) +} + +func TestTOMLTable(t *testing.T) { + assert.Equal(t, "[chain]", TOML{}.Table("chain")) + assert.Equal(t, "[chain.nodes]", TOML{}.Table("chain.nodes")) +} + +func TestTOMLField(t *testing.T) { + f := TOML{} + assert.Equal(t, "host = 'x' # Default", f.Field("host", "'x'", f.DefaultMarker())) + assert.Equal(t, "host = 'x' # Example", f.Field("host", "'x'", f.ExampleMarker())) + // No marker: a plain value line, for the example config rather than the docs. + assert.Equal(t, "host = 'x'", f.Field("host", "'x'", "")) +} + +func TestTOMLMarkers(t *testing.T) { + assert.Equal(t, FieldDefault, TOML{}.DefaultMarker()) + assert.Equal(t, FieldExample, TOML{}.ExampleMarker()) +} + +func TestTOMLParseLine(t *testing.T) { + for _, tt := range []struct { + name string + line string + want Line + }{ + {"comment", "# the host", Line{Kind: LineComment, Text: "the host"}}, + {"comment without space", "#the host", Line{Kind: LineComment, Text: "the host"}}, + {"empty", "", Line{Kind: LineBlank}}, + {"whitespace only", " ", Line{Kind: LineBlank}}, + {"table", "[chain]", Line{Kind: LineTable, Text: "chain"}}, + {"nested table", "[chain.nodes]", Line{Kind: LineTable, Text: "chain.nodes"}}, + {"array of tables", "[[EVM]]", Line{Kind: LineArrayOfTables, Text: "EVM"}}, + {"field", "host = 'x' # Default", Line{Kind: LineField, Text: "host"}}, + {"field with no marker", "host = 'x'", Line{Kind: LineField, Text: "host"}}, + {"indented field", " host = 'x'", Line{Kind: LineField, Text: "host"}}, + // Degenerate, but must not panic - Text is simply the whole line. + {"field with no space", "host", Line{Kind: LineField, Text: "host"}}, + } { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, TOML{}.ParseLine(tt.line)) + }) + } +} + +// ParseLine has to read back whatever the emitting side writes, or a document assembled with +// this format can't be turned into documentation by it. +func TestTOMLEmitAndParseAgree(t *testing.T) { + f := TOML{} + + assert.Equal(t, Line{Kind: LineComment, Text: "the host"}, f.ParseLine(f.Comment("the host"))) + assert.Equal(t, Line{Kind: LineTable, Text: "chain.nodes"}, f.ParseLine(f.Table("chain.nodes"))) + assert.Equal(t, Line{Kind: LineField, Text: "host"}, f.ParseLine(f.Field("host", "'x'", f.DefaultMarker()))) + assert.Equal(t, Line{Kind: LineField, Text: "host"}, f.ParseLine(f.Field("host", "'x'", ""))) +} + +func TestTOMLLiteral(t *testing.T) { + for _, tt := range []struct { + name string + val any + want string + }{ + {"string", "example.com", "'example.com'"}, + {"empty string", "", "''"}, + {"bool", true, "true"}, + {"int", 42, "42"}, + {"negative int", -7, "-7"}, + {"sized int", int32(42), "42"}, + {"uint", uint32(42), "42"}, + {"float", 1.5, "1.5"}, + {"nil", nil, "''"}, + {"string slice", []string{"a", "b"}, "['a', 'b']"}, + {"empty slice", []string{}, "[]"}, + {"int slice", []int{1, 2}, "[1, 2]"}, + // A duration is an int64 underneath; it must not render as a raw nanosecond count. + {"duration", 90 * time.Second, "'1m30s'"}, + } { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, TOML{}.Literal(tt.val)) + }) + } +} + +func TestTOMLLiteralUsesTextMarshaler(t *testing.T) { + // config.Duration marshals itself to text, so it round-trips through the same + // UnmarshalText that reads it back rather than being printed as a struct. + d := config.MustNewDuration(90 * time.Second) + + assert.Equal(t, "'1m30s'", TOML{}.Literal(*d)) + assert.Equal(t, "'1m30s'", TOML{}.Literal(d), "a pointer is dereferenced first") +} + +func TestTOMLLiteralNilPointer(t *testing.T) { + var d *config.Duration + assert.Equal(t, "''", TOML{}.Literal(d)) +} + +// The literals this format writes have to be valid input for the document it produces, so a +// generated document round-trips through Generate without tripping its own parser. +func TestTOMLLiteralsRoundTripThroughGenerate(t *testing.T) { + f := TOML{} + + doc := f.Comment("host the host") + "\n" + + f.Field("host", f.Literal("example.com"), f.DefaultMarker()) + "\n" + + f.Comment("retries how many retries") + "\n" + + f.Field("retries", f.Literal(3), f.DefaultMarker()) + "\n" + + f.Comment("timeout how long to wait") + "\n" + + f.Field("timeout", f.Literal(90*time.Second), f.ExampleMarker()) + "\n" + + out, err := GenerateWith(f, doc, "# Docs\n", "", nil) + require.NoError(t, err) + assert.Contains(t, out, "host = 'example.com' # Default") + assert.Contains(t, out, "retries = 3 # Default") + assert.Contains(t, out, "timeout = '1m30s' # Example") +} + +func TestGenerateWithUsesFormatName(t *testing.T) { + out, err := GenerateWith(TOML{}, "# host the host\nhost = 'x' # Default\n", "# Docs\n", "host = 'x'", nil) + require.NoError(t, err) + + // The code fences are labelled with the format, exactly once. + assert.Contains(t, out, "```toml\n") + assert.NotContains(t, out, "```tomltoml") +} diff --git a/pkg/config/flags/docs.go b/pkg/config/flags/docs.go new file mode 100644 index 0000000000..b57cdd7a23 --- /dev/null +++ b/pkg/config/flags/docs.go @@ -0,0 +1,557 @@ +package flags + +import ( + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + + "github.com/spf13/cobra" + + "github.com/smartcontractkit/chainlink-common/pkg/config/configdoc" +) + +// docsOutputPath is where the "docs" subcommand writes generated documentation, mirroring +// chainlink core's docs/CONFIG.md convention. +const docsOutputPath = "docs/CONFIG.md" + +// addDocsCommand registers a "docs" subcommand on root (if one isn't already present) that +// writes generated Markdown documentation - covering root's registered config struct and +// every descendant subcommand's registered config struct - to docsOutputPath. +// +// opts.GenerateDoc is recorded on the command so the whole document renders with the generator +// the caller asked for. The first registration wins, since one document can only be rendered +// one way however many targets contribute to it. +func addDocsCommand(root *cobra.Command, opts Options) { + meta := getOrCreateMeta(root) + if meta.docFormat == nil { + meta.docFormat = opts.format() + } + + for _, c := range root.Commands() { + if c.Name() == "docs" { + return + } + } + + root.AddCommand(&cobra.Command{ + Use: "docs", + Short: "Write generated configuration documentation to " + docsOutputPath, + // Documenting the config must not require a valid config. Defining any + // PersistentPreRunE here shadows the root's (cobra runs only the nearest one), + // which is what skips the decode + `validate:"required"` step for this command. + PersistentPreRunE: func(*cobra.Command, []string) error { return nil }, + RunE: func(cmd *cobra.Command, args []string) error { + doc, err := GenerateDocs(cmd.Root()) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(docsOutputPath), 0o755); err != nil { + return fmt.Errorf("failed to create %s: %w", filepath.Dir(docsOutputPath), err) + } + if err := os.WriteFile(docsOutputPath, []byte(doc), 0o600); err != nil { + return fmt.Errorf("failed to write %s: %w", docsOutputPath, err) + } + fmt.Println("Wrote", docsOutputPath) + return nil + }, + }) +} + +// GenerateDocs generates Markdown documentation covering root's registered config struct and +// every descendant subcommand's registered config struct (each under its own namespaced TOML +// table), by deriving TOML doc lines directly from struct tags: +// - `toml`/`mapstructure` (or the lowercased field name) supplies the key, same convention +// used for CLI/env/viper binding. +// - `usage` supplies the field's (or, for a nested struct field, the table's) description. +// - a field tagged `validate:"required"` (the same tag checked at runtime, see +// decodeAndApplyProfiles) is documented as configdoc.FieldExample instead of +// configdoc.FieldDefault, since it has no real default; its `example` tag supplies the +// placeholder value shown. +// - a `flagdocs` tag adjusts how the field appears in the example config; see flagDocsTag. +// +// Arrays/slices of structs ([[array-of-tables]] in hand-written docs) are not supported. +// +// Each command's block is preceded by a divider heading - "Global Configuration" for root, +// "Command: " for every subcommand - naming exactly which `myapp ...` invocation the +// fields below it apply to, since root and subcommand fields would otherwise render as +// visually identical tables with no indication of which command binds them. +func GenerateDocs(root *cobra.Command) (string, error) { + var toml, example strings.Builder + + // The rendering step belongs to the whole document, so it uses the format recorded on the + // root command; per-entry options still drive how each entry's own fields are read. + format := Options{}.format() + if rootMeta := getMeta(root); rootMeta != nil && rootMeta.docFormat != nil { + format = rootMeta.docFormat + } + + var walkCmd func(cmd *cobra.Command) error + walkCmd = func(cmd *cobra.Command) error { + if meta := getMeta(cmd); meta != nil && len(meta.entries) > 0 { + label := "Global Configuration" + if cmd != root { + label = "Command: " + cmd.CommandPath() + } + + var cmdToml, cmdExample strings.Builder + for _, entry := range meta.entries { + if entry.target == nil { + continue + } + + section, err := structToDocs(entry.target, entry.namespace, entry.opts) + if err != nil { + return fmt.Errorf("%s: %w", cmd.CommandPath(), err) + } + cmdToml.WriteString(section) + + exampleSection, err := exampleDoc(entry.target, entry.namespace, entry.opts) + if err != nil { + return fmt.Errorf("%s: %w", cmd.CommandPath(), err) + } + cmdExample.WriteString(exampleSection) + } + + // A comment whose text itself starts with "#" renders as a standalone H1 divider + // (the format strips its own comment marker, leaving the markdown one), set apart + // from the H2 table headings structToDocs emits. The blank line after it keeps it + // from being read as the next item's description. + toml.WriteString(format.Comment("# "+label) + "\n\n" + cmdToml.String()) + example.WriteString(format.Comment("----- "+label+" -----") + "\n" + cmdExample.String() + "\n") + } + for _, sub := range cmd.Commands() { + if err := walkCmd(sub); err != nil { + return err + } + } + return nil + } + + if err := walkCmd(root); err != nil { + return "", err + } + + header := fmt.Sprintf("# %s Configuration\n", root.Name()) + return configdoc.GenerateWith(format, toml.String(), header, example.String(), nil) +} + +// exampleDoc renders target's fields as plain, directly-usable TOML - real default values, +// and for `validate:"required"` fields (which have none), the placeholder from their +// `example` tag - namespaced the same way structToDocs is, so it can double as a working +// example config file for a namespaced subcommand. +func exampleDoc(target any, namespace string, opts Options) (string, error) { + var sb strings.Builder + f := opts.format() + chosen := newExclusiveChoice() + currentSection := "" + + if namespace != "" { + sb.WriteString(f.Table(namespace) + "\n") + currentSection = namespace + } + + err := walkStruct(target, opts, structVisitor{ + branch: func(m fieldMeta) (bool, error) { + if omitFromExample(m.field) || chosen.excluded(m, opts) { + return true, nil + } + chosen.keep(m) + + // A squashed struct contributes no key of its own, so it gets no table: its fields + // belong to the enclosing one. Checked before the namespace is prefixed, or a + // squashed struct under a namespace would open an empty "[namespace.]" table. + section := m.key() + if section == "" { + return false, nil + } + if namespace != "" { + section = namespace + "." + section + } + if section == currentSection { + return false, nil + } + currentSection = section + sb.WriteString("\n" + f.Table(section) + "\n") + return false, nil + }, + leaf: func(m fieldMeta) error { + if omitFromExample(m.field) || chosen.excluded(m, opts) { + return nil + } + chosen.keep(m) + + key := m.keyPath[len(m.keyPath)-1] + value := f.Literal(m.elem.Interface()) + if hasNoRealDefault(m.field) { + if ex := m.field.Tag.Get("example"); ex != "" { + value = ex + } + } + if override, ok := exampleOverride(m.field); ok { + value = override + } + sb.WriteString(f.Field(key, value, "") + "\n") + return nil + }, + }) + if err != nil { + return "", err + } + + return sb.String(), nil +} + +// structToDocs renders target's fields as annotated config-document lines in opts' format. If namespace is +// non-empty, all of target's keys are nested under a "[namespace]" table (and further nested +// structs under "[namespace.sub]", etc) instead of the top-level "Global" bucket - this is how +// a subcommand's settings (bound under that namespace by RegisterSubcommandFlags) are kept +// from colliding with the root command's own tables. +func structToDocs(target any, namespace string, opts Options) (string, error) { + var sb strings.Builder + f := opts.format() + currentSection := "" + + targetType := reflect.TypeOf(target) + for targetType != nil && targetType.Kind() == reflect.Pointer { + targetType = targetType.Elem() + } + + if namespace != "" { + // No description of its own: a namespace is a grouping, and the command's Short + // describes the command rather than this slice of its configuration. Squashed structs + // underneath still contribute theirs. + writeComments(&sb, f, squashedUsages(targetType, opts)) + sb.WriteString(f.Table(namespace) + "\n") + currentSection = namespace + } else if usages := squashedUsages(targetType, opts); len(usages) > 0 { + // No table header to hang them on, so they become standalone prose. A comment block + // followed by a blank line is a free-standing item to configdoc; without the blank + // line it would instead be read as the next field's description. + writeComments(&sb, f, usages) + sb.WriteString("\n") + } + + err := walkStruct(target, opts, structVisitor{ + branch: func(m fieldMeta) (bool, error) { + // A squashed struct contributes no section of its own, so its description would + // have nowhere to go here; it is folded into the enclosing table's header instead + // (see squashedUsages), alongside that table's own description. Checked before the + // namespace is prefixed, or a squashed struct under a namespace would open an empty + // "[namespace.]" table. + section := m.key() + if section == "" { + return false, nil + } + if namespace != "" { + section = namespace + "." + section + } + if section == currentSection { + return false, nil + } + currentSection = section + + writeComments(&sb, f, append(nonEmpty(m.field.Tag.Get("usage")), squashedUsages(m.elemType, opts)...)) + sb.WriteString(f.Table(section) + "\n") + return false, nil + }, + leaf: func(m fieldMeta) error { + key := m.keyPath[len(m.keyPath)-1] + + desc := m.field.Tag.Get("usage") + switch { + case desc == "": + desc = key + case !strings.HasPrefix(desc, key): + desc = key + " " + desc + } + sb.WriteString(f.Comment(desc+constraintNote(m, opts)) + "\n") + + marker := f.DefaultMarker() + value := f.Literal(m.elem.Interface()) + if hasNoRealDefault(m.field) { + marker = f.ExampleMarker() + if ex := m.field.Tag.Get("example"); ex != "" { + value = ex + } + } + if omitFromExample(m.field) { + // Documented, but absent from the example config and from its own table's + // code block, so every example in the document describes the same setup. + marker = f.DocsOnlyMarker() + } + + sb.WriteString(f.Field(key, value, marker) + "\n\n") + return nil + }, + }) + if err != nil { + return "", err + } + + return sb.String(), nil +} + +// isRequired reports whether field carries a `required` validate rule - either the plain +// `required` or one of validator's conditional forms (`required_without=Other`, +// `required_if=...`, etc). Conditionally-required fields have no usable default either, so +// they're documented as FieldExample the same way. +// squashedUsages returns the `usage` descriptions of t's squashed struct fields, recursively. +// Squashed structs are flattened into their parent and get no table of their own, so their +// descriptions are reported as part of the enclosing table's header - otherwise they'd be lost, +// and emitting them next to their fields would make configdoc read them as the following +// field's description. +func squashedUsages(t reflect.Type, opts Options) []string { + if t == nil || t.Kind() != reflect.Struct { + return nil + } + + var usages []string + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + if field.PkgPath != "" { + continue + } + + elemType := field.Type + if elemType.Kind() == reflect.Pointer { + elemType = elemType.Elem() + } + if elemType.Kind() != reflect.Struct || implementsTextUnmarshaler(elemType) { + continue + } + if _, squash := opts.tagKey(field); !squash { + continue + } + + usages = append(usages, nonEmpty(field.Tag.Get("usage"))...) + usages = append(usages, squashedUsages(elemType, opts)...) + } + return usages +} + +// exclusiveChoice tracks which of a set of mutually exclusive fields the example already +// shows. An example config has to be one that actually works, and "exactly one of these" +// cannot be illustrated by listing all of them - so the first one declared wins and the rest +// are left out. The docs still describe every option; only the example has to choose. +// +// Fields are recorded by their full config key rather than by the Go struct and field name +// declaring them, so the rules resolve the same way validator does at runtime: a squashed +// struct's fields are promoted into its parent's table, and a rule's parameter may be a dotted +// path reaching down into a nested struct's own table. +type exclusiveChoice map[string]bool + +func newExclusiveChoice() exclusiveChoice { return exclusiveChoice{} } + +// keep records that the example shows this field. +func (c exclusiveChoice) keep(m fieldMeta) { + c[strings.Join(m.keyPath, ".")] = true +} + +// excluded reports whether a field the example already shows rules this one out. +func (c exclusiveChoice) excluded(m fieldMeta, opts Options) bool { + if len(c) == 0 { + return false + } + for _, rule := range strings.Split(m.field.Tag.Get("validate"), ",") { + name, args, hasArgs := strings.Cut(rule, "=") + if !hasArgs || !strings.HasPrefix(name, "excluded_with") { + continue + } + for _, goPath := range strings.Fields(args) { + if key, ok := ruleTargetKey(m, goPath, opts); ok && c[key] { + return true + } + } + } + return false +} + +// ruleTargetKey resolves a cross-field rule's parameter to the full config key of the field it +// names, in the same terms keep records: validator resolves the parameter from the struct +// declaring the rule, so the key is that struct's own key path followed by the resolved path. +func ruleTargetKey(m fieldMeta, goPath string, opts Options) (string, bool) { + keys, ok := siblingKeyPath(m.parent, goPath, opts) + if !ok { + return "", false + } + + // The declaring struct's key path is m's without its own segment - unless m is itself + // squashed and so contributed none. + prefix := m.keyPath + if _, squash := opts.tagKey(m.field); !squash && len(prefix) > 0 { + prefix = prefix[:len(prefix)-1] + } + + full := make([]string, 0, len(prefix)+len(keys)) + full = append(full, prefix...) + full = append(full, keys...) + return strings.Join(full, "."), true +} + +// writeComments emits each line as a comment in f's syntax. +func writeComments(sb *strings.Builder, f configdoc.Format, lines []string) { + for _, l := range lines { + sb.WriteString(f.Comment(l) + "\n") + } +} + +// nonEmpty returns s as a one-element slice, or nothing if s is empty. +func nonEmpty(s string) []string { + if s == "" { + return nil + } + return []string{s} +} + +// The `flagdocs` tag adjusts how a field appears in the generated example config, without +// changing the field itself or how it is documented. It holds one directive: +// +// flagdocs:"noexample" leave the field out of every example (see configdoc.FieldDocsOnly) +// flagdocs:"example=" use this literal in the example instead of the field's default +// +// Use noexample for a setting the example must not carry - one that only applies in a mode the +// example isn't showing - where including it would describe a setup nobody should copy. Use +// example= where the field's own default would make the example wrong or unhelpful. +// +// The directive is taken whole, so an example= literal may itself contain commas. +const flagDocsTag = "flagdocs" + +// omitFromExample reports whether a field is kept out of the example config. +func omitFromExample(field reflect.StructField) bool { + return field.Tag.Get(flagDocsTag) == "noexample" +} + +// exampleOverride returns the literal to show for a field in the example config, if the field +// asked for one. +func exampleOverride(field reflect.StructField) (string, bool) { + const prefix = "example=" + tag := field.Tag.Get(flagDocsTag) + if !strings.HasPrefix(tag, prefix) { + return "", false + } + return strings.TrimPrefix(tag, prefix), true +} + +// hasNoRealDefault reports whether a field's zero value is a placeholder rather than a usable +// default, so the docs should show it as configdoc.FieldExample. That's true when the field is +// required (its value must be supplied), and also whenever it carries an `example` tag - which +// is how a field says "there is nothing sensible to default to" even when the rule making it +// required lives outside this struct and so can't be a `validate` tag. +func hasNoRealDefault(field reflect.StructField) bool { + return isRequired(field) || field.Tag.Get("example") != "" +} + +func isRequired(field reflect.StructField) bool { + for _, rule := range strings.Split(field.Tag.Get("validate"), ",") { + if name, _, _ := strings.Cut(rule, "="); name == "required" || strings.HasPrefix(name, "required_") { + return true + } + } + return false +} + +// crossFieldRules are the validator rules whose outcome depends on a sibling field, and which +// therefore need a field to be distinguishably "absent" rather than merely zero. +var crossFieldRules = map[string]bool{ + "required_with": true, "required_with_all": true, + "required_without": true, "required_without_all": true, + "excluded_with": true, "excluded_with_all": true, + "excluded_without": true, "excluded_without_all": true, +} + +// crossFieldRuleNames returns the cross-field rule names present on field, in tag order. +func crossFieldRuleNames(field reflect.StructField) []string { + var names []string + for _, rule := range strings.Split(field.Tag.Get("validate"), ",") { + if name, _, hasArgs := strings.Cut(rule, "="); hasArgs && crossFieldRules[name] { + names = append(names, name) + } + } + return names +} + +// constraintNote renders a field's cross-field `validate` rules as a human-readable sentence +// (e.g. "must not be set unless database-url is set"), so the generated docs explain when a +// conditionally required or mutually exclusive setting actually applies. Sibling fields named +// by the rules are reported by their config key rather than their Go field name. +func constraintNote(m fieldMeta, opts Options) string { + var notes []string + for _, rule := range strings.Split(m.field.Tag.Get("validate"), ",") { + name, args, hasArgs := strings.Cut(rule, "=") + if !hasArgs { + continue + } + + var keys []string + for _, goName := range strings.Fields(args) { + keys = append(keys, siblingKey(m.parent, goName, opts)) + } + list := strings.Join(keys, ", ") + + switch name { + case "required_with": + notes = append(notes, "required when "+list+" is set") + case "required_with_all": + notes = append(notes, "required when all of "+list+" are set") + case "required_without": + notes = append(notes, "required unless "+list+" is set") + case "required_without_all": + notes = append(notes, "required unless all of "+list+" are set") + case "excluded_with": + notes = append(notes, "must not be set when "+list+" is set") + case "excluded_with_all": + notes = append(notes, "must not be set when all of "+list+" are set") + case "excluded_without": + notes = append(notes, "must not be set unless "+list+" is set") + case "excluded_without_all": + notes = append(notes, "must not be set unless all of "+list+" are set") + } + } + if len(notes) == 0 { + return "" + } + return " (" + strings.Join(notes, "; ") + ")" +} + +// siblingKey maps a Go field path within parent to its dotted config key, falling back to the Go +// path if it doesn't resolve. +func siblingKey(parent reflect.Type, goPath string, opts Options) string { + if keys, ok := siblingKeyPath(parent, goPath, opts); ok { + return strings.Join(keys, ".") + } + return goPath +} + +// siblingKeyPath resolves a cross-field rule's parameter - a Go field name, or a dotted path into +// a nested struct ("Mid.Deep.Bar"), the way go-playground/validator resolves it from the struct +// carrying the rule - into the config keys of the fields along it. A squashed struct contributes +// no key of its own, matching how its fields are bound. +func siblingKeyPath(parent reflect.Type, goPath string, opts Options) ([]string, bool) { + current := parent + var keys []string + for _, name := range strings.Split(goPath, ".") { + if current == nil || current.Kind() != reflect.Struct { + return nil, false + } + field, ok := current.FieldByName(name) + if !ok { + return nil, false + } + + if key, squash := opts.tagKey(field); !squash { + keys = append(keys, key) + } + + current = field.Type + for current.Kind() == reflect.Pointer { + current = current.Elem() + } + } + if len(keys) == 0 { + return nil, false + } + return keys, true +} + diff --git a/pkg/config/flags/flags.go b/pkg/config/flags/flags.go new file mode 100644 index 0000000000..472ed9195e --- /dev/null +++ b/pkg/config/flags/flags.go @@ -0,0 +1,839 @@ +package flags + +import ( + "errors" + "fmt" + "os" + "reflect" + "strings" + "sync" + "time" + + "github.com/go-playground/validator/v10" + "github.com/go-viper/mapstructure/v2" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/spf13/viper" + + "github.com/smartcontractkit/chainlink-common/pkg/config" + "github.com/smartcontractkit/chainlink-common/pkg/config/configdoc" +) + +// durationType lets bindLeafFlag recognize a time.Duration field (Kind() is Int64, same as +// any plain int64) and bind it as a pflag Duration ("5s" CLI syntax) instead of a raw integer. +var durationType = reflect.TypeOf(time.Duration(0)) + +// configDurationType is config.Duration, the non-negative duration used by config structs +// across chainlink. It's a TextMarshaler/TextUnmarshaler, so it would otherwise bind as an +// untyped string flag; special-casing it keeps the typed "duration" pflag (and pflag's own +// parse errors) while still decoding through its UnmarshalText. +var configDurationType = reflect.TypeOf(config.Duration{}) + +// validate runs `validate:"..."` struct tag checks (e.g. `required`) against a fully +// decoded target, after config file/flags/env/profile defaulting has all been applied. +var validate = sync.OnceValue(func() *validator.Validate { return validator.New() }) + +type profileApplier func(cmd *cobra.Command, target any) error + +// targetEntry is one struct registered against a command via RegisterCommandFlags or +// RegisterSubcommandFlags. A single command can have multiple entries - e.g. several +// independent plugins/dependencies each registering their own config struct on a shared root +// command - decoded and validated independently of one another. +type targetEntry struct { + // namespace roots this entry's config keys. + namespace string + // flagPrefix roots this entry's flag names. It matches namespace for a root-command + // registration, where several structs share one flag set and would otherwise collide, but + // is empty for a subcommand, whose own name already separates it from its siblings. + flagPrefix string + prefixes []string + target any + opts Options + profiles []profileApplier + + // keys is every leaf bound for this target, recorded at registration so decoding can + // resolve exactly this entry's keys (and no other entry's) through viper's normal + // precedence. + keys []leafKey +} + +// leafKey ties a target field's position within its own struct (relPath) to the viper key it +// was bound under (viperKey, which carries the entry's namespace prefix, if any) and the CLI +// flag registered for it (flagName, which does not - it's derived from the key relative to the +// target, so it can't be recomputed from viperKey for a namespaced entry). +type leafKey struct { + relPath []string + viperKey string + flagName string +} + +type commandMetaData struct { + entries []*targetEntry + + // docFormat is the syntax documentation for this command's whole tree is written in, + // taken from the Options of the registration that added the docs command (see + // addDocsCommand). + docFormat configdoc.Format + + // hookWired guards against chaining a redundant decode step onto PersistentPreRunE/PreRunE + // every time RegisterCommandFlags/RegisterSubcommandFlags is called again for this command; + // the single wired hook always decodes every entry (see decodeAndApplyProfiles). + hookWired bool +} + +var ( + registryMu sync.RWMutex + cmdRegistry = make(map[*cobra.Command]*commandMetaData) +) + +func getOrCreateMeta(cmd *cobra.Command) *commandMetaData { + registryMu.Lock() + defer registryMu.Unlock() + + meta, exists := cmdRegistry[cmd] + if !exists { + meta = &commandMetaData{} + cmdRegistry[cmd] = meta + } + return meta +} + +func getMeta(cmd *cobra.Command) *commandMetaData { + registryMu.RLock() + defer registryMu.RUnlock() + return cmdRegistry[cmd] +} + +// RegisterCommandFlags binds struct fields as CLI persistent flags, Viper defaults, and env vars. +// It also wires an automatic decode step (config file + flags + env + registered profiles) into +// target, running before cmd's own PersistentPreRunE (if any). +// +// It's safe to call this (and/or RegisterSubcommandFlags) more than once for the same cmd with +// different targets - e.g. several independent dependencies each registering their own config +// struct on a shared root command - each target is decoded, profile-defaulted, and validated +// independently. +// +// Fields are validated with go-playground/validator `validate` tags after decoding, so a rule +// sees the value whatever supplied it (flag, env, config file, or profile). Mutually exclusive +// or conditionally required *sections* must be pointer fields, so that "not configured" is +// representable: +// +// Local *LocalConfig `toml:"local" validate:"required_without=Proxy,excluded_with=Proxy"` +// Proxy *ProxyConfig `toml:"proxy" validate:"required_without=Local,excluded_with=Local"` +// +// Registering a non-pointer struct with such a rule is an error, since a value struct is never +// absent and the rule could not work. +// +// opts controls the tag conventions, decoding, env prefixes, and doc generation; see +// DefaultTOMLOptions. +func RegisterCommandFlags(cmd *cobra.Command, target any, opts Options) error { + meta := getOrCreateMeta(cmd) + entry := &targetEntry{ + namespace: opts.Namespace, + flagPrefix: opts.Namespace, + prefixes: opts.Prefixes, + target: target, + opts: opts, + } + meta.entries = append(meta.entries, entry) + + if err := registerStructFlagsInternal(cmd, entry, false); err != nil { + return err + } + + wireDecodeHook(cmd, meta, true) + addDocsCommand(cmd, opts) + return nil +} + +// RegisterSubcommandFlags registers local flags on a subcommand, inheriting the root command's +// env prefixes when opts sets none. It also wires an automatic decode step for target, running +// before cmd's own PreRunE (if any). See RegisterCommandFlags for the multiple-targets-per-command +// note. +// +// namespace roots the keys and env vars, as it does on a root command, but by default not the flag +// names: a subcommand's settings are usually namespaced by the subcommand itself, so "sub" gives +// the key sub.retries and the flag --retries, typed as `sub --retries`. Set opts.Namespace as well +// to prefix the flags too, for a config namespaced by whatever owns it rather than by the command +// it happens to hang off - two dependencies registered on one subcommand need that, or their +// same-named settings would collide in a flag set that neither of them names. +func RegisterSubcommandFlags(cmd *cobra.Command, namespace string, target any, opts Options) error { + meta := getOrCreateMeta(cmd) + // With no prefixes of its own, the entry inherits the root command's at decode time, since + // cmd usually hasn't been attached to its parent yet (see effectivePrefixes). + entry := &targetEntry{namespace: namespace, flagPrefix: opts.Namespace, prefixes: opts.Prefixes, target: target, opts: opts} + meta.entries = append(meta.entries, entry) + + if err := registerStructFlagsInternal(cmd, entry, true); err != nil { + return err + } + + wireDecodeHook(cmd, meta, false) + return nil +} + +// effectivePrefixes returns the entry's own env-var prefixes, or - for a subcommand entry that +// didn't specify any - the union of those registered on cmd's root command. +// +// This is resolved when the command runs rather than when it's registered: a subcommand is +// typically registered with its config before rootCmd.AddCommand(sub) is called, so at +// registration time sub.Root() is still sub itself and the root's prefixes aren't reachable yet. +func (e *targetEntry) effectivePrefixes(cmd *cobra.Command) []string { + if len(e.prefixes) > 0 { + return e.prefixes + } + + rootMeta := getMeta(cmd.Root()) + if rootMeta == nil { + return nil + } + + seen := make(map[string]bool) + var prefixes []string + for _, re := range rootMeta.entries { + for _, p := range re.prefixes { + if !seen[p] { + seen[p] = true + prefixes = append(prefixes, p) + } + } + } + return prefixes +} + +// bindEnv binds each of the entry's keys to its PREFIX_UPPER_SNAKE env vars. Called at decode +// time for the same reason effectivePrefixes is resolved there. +func (e *targetEntry) bindEnv(prefixes []string) { + if len(prefixes) == 0 { + return + } + for _, k := range e.keys { + envSuffix := strings.ToUpper(strings.ReplaceAll(strings.ReplaceAll(k.viperKey, ".", "_"), "-", "_")) + bindArgs := []string{k.viperKey} + for _, prefix := range prefixes { + bindArgs = append(bindArgs, strings.TrimSuffix(strings.ToUpper(prefix), "_")+"_"+envSuffix) + } + _ = viper.BindEnv(bindArgs...) + } +} + +// wireDecodeHook chains a decode step in front of whatever PreRunE/PersistentPreRunE the command +// already has, so the caller's own hook (if any) observes already-populated targets. Only wires +// once per command - later calls (from additional RegisterCommandFlags/RegisterSubcommandFlags +// calls on the same cmd) are no-ops here since decodeAndApplyProfiles always walks every +// registered entry for the command. +func wireDecodeHook(cmd *cobra.Command, meta *commandMetaData, persistent bool) { + if meta.hookWired { + return + } + meta.hookWired = true + + decode := func(c *cobra.Command, args []string) error { + return decodeAndApplyProfiles(c, meta) + } + + if persistent { + prev := cmd.PersistentPreRunE + cmd.PersistentPreRunE = func(c *cobra.Command, args []string) error { + if err := decode(c, args); err != nil { + return err + } + if prev != nil { + return prev(c, args) + } + return nil + } + return + } + + prev := cmd.PreRunE + cmd.PreRunE = func(c *cobra.Command, args []string) error { + if err := decode(c, args); err != nil { + return err + } + if prev != nil { + return prev(c, args) + } + return nil + } +} + +// decodeAndApplyProfiles loads the optional config file, then for every entry registered +// against cmd: unmarshals Viper state into entry.target, applies profiles registered for that +// entry, and validates `validate:"required"` tags. Entries are independent - one entry's +// decode/validation failure doesn't stop the others from being decoded and validated too, so +// e.g. two dependencies sharing a command each get to report their own missing-required-field +// errors in the same run instead of one hiding the other. +func decodeAndApplyProfiles(cmd *cobra.Command, meta *commandMetaData) error { + // Cobra's own commands describe the program rather than run it, so they must work on a + // machine that has no valid configuration - asking someone to satisfy every required + // setting before they can read the help that tells them what those settings are would be + // backwards. They are generated when the command runs, too late to opt out at + // registration the way the docs command does, so they're recognized here. + if IsBuiltinCommand(cmd) { + return nil + } + + // Load into the (global) viper before reading any key. cmd here is the command actually + // being executed, not the one this hook was registered on, so it can't be used to tell + // "am I the root" - hence loading once per process rather than only on the root's pass. + if err := loadConfigFileOnce(cmd); err != nil { + return err + } + + var errs []error + for _, entry := range meta.entries { + if entry.target == nil { + continue + } + + // Resolve prefixes and bind env vars now that the command tree is fully assembled. + entry.prefixes = entry.effectivePrefixes(cmd) + entry.bindEnv(entry.prefixes) + + if err := decodeEntry(cmd, entry); err != nil { + errs = append(errs, err) + continue + } + + var applierErr error + for _, applier := range entry.profiles { + if err := applier(cmd, entry.target); err != nil { + applierErr = err + break + } + } + if applierErr != nil { + errs = append(errs, applierErr) + continue + } + + // Check `validate:"required"` (and any other validator tags) only now that config + // file/flags/env/profile defaulting have all had a chance to fill fields in - a field + // can be required yet still end up populated by a profile rather than the user directly. + if err := validate().Struct(entry.target); err != nil { + errs = append(errs, fmt.Errorf("invalid configuration: %w", err)) + } + } + + return errors.Join(errs...) +} + +// decodeEntry resolves each of entry's registered leaf keys through viper (so CLI flag / env +// var / config file / default precedence applies per key), assembles them into a nested map +// shaped like entry.target, and decodes that into the target. +// +// This is deliberately not viper.Unmarshal/UnmarshalKey: Unmarshal decodes the whole tree, so +// a namespaced entry's "foo.timeout" would never line up with its plain "timeout" field, and +// UnmarshalKey is decode(viper.Get(namespace)), which returns whichever single source holds +// that subtree - it does not merge per-key flag/env overrides underneath it. Both silently +// leave fields at their compiled-in defaults rather than erroring. +// Only keys the user actually supplied (changed flag, config file entry, or env var) are +// included. Fields nobody set keep whatever the caller's struct was constructed with, which is +// what makes an optional nested *struct stay nil when nothing under it was provided - decoding +// its defaults would allocate it and defeat `required_without`/`excluded_with` on that field. +func decodeEntry(cmd *cobra.Command, entry *targetEntry) error { + settings := map[string]any{} + for _, k := range entry.keys { + if !isExplicitlySet(cmd, k.flagName, k.viperKey, entry.prefixes) { + continue + } + val := viper.Get(k.viperKey) + if val == nil { + continue + } + setPath(settings, k.relPath, val) + } + + decoder, err := mapstructure.NewDecoder(entry.opts.decoderConfigFor(entry.target)) + if err != nil { + return err + } + return decoder.Decode(settings) +} + +// setPath assigns val at the nested path within m, creating intermediate maps as needed. +func setPath(m map[string]any, path []string, val any) { + for _, p := range path[:len(path)-1] { + next, ok := m[p].(map[string]any) + if !ok { + next = map[string]any{} + m[p] = next + } + m = next + } + m[path[len(path)-1]] = val +} + +var ( + configFileOnce = new(sync.Once) + configFileErr error +) + +// loadConfigFileOnce reads the config file into viper the first time it's called, since viper +// is process-global and several commands' decode hooks can run in one execution. +func loadConfigFileOnce(cmd *cobra.Command) error { + configFileOnce.Do(func() { configFileErr = loadConfigFile(cmd) }) + return configFileErr +} + +// IsBuiltinCommand reports whether cmd is one of cobra's generated commands (help, completion, +// and the hidden completion callbacks), or lives under one. +// +// Decoding and validation are skipped for these automatically. Callers that chain their own +// PersistentPreRunE/PreRunE with extra checks - rules spanning several config structs, say - +// should return early on it too, or those checks will reject `help` on a machine that has no +// configuration yet. +func IsBuiltinCommand(cmd *cobra.Command) bool { + for c := cmd; c != nil; c = c.Parent() { + switch c.Name() { + case "help", "completion", cobra.ShellCompRequestCmd, cobra.ShellCompNoDescRequestCmd: + return true + } + } + return false +} + +func loadConfigFile(cmd *cobra.Command) error { + configFile, _ := cmd.Flags().GetString("config") + + if configFile != "" { + viper.SetConfigFile(configFile) + if err := viper.ReadInConfig(); err != nil { + return fmt.Errorf("failed to read specified config file %q: %w", configFile, err) + } + return nil + } + + viper.AddConfigPath(".") + viper.SetConfigName("config") + viper.SetConfigType("toml") + + if err := viper.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + return fmt.Errorf("failed to parse config file: %w", err) + } + } + return nil +} + +// RegisterProfile attaches a profile map to a command using a selector field path (e.g. "Chain.ID" or "chain.id"). +// T must match the type of a struct already registered for this command via +// RegisterCommandFlags/RegisterSubcommandFlags - if more than one entry of type T is registered +// on cmd, or none are, this returns an error (call RegisterCommandFlags/RegisterSubcommandFlags +// first, and don't register two same-typed structs on one command if you also need a profile). +func RegisterProfile[T any, K comparable]( + cmd *cobra.Command, + selectorFieldName string, + profiles map[K]T, + opts Options, +) error { + meta := getOrCreateMeta(cmd) + + var zero T + targetType := reflect.TypeOf(zero) + if targetType.Kind() == reflect.Pointer { + targetType = targetType.Elem() + } + + entry, err := findEntryByTargetType(meta, targetType) + if err != nil { + return err + } + + selectorPath, err := verifySelectorType(targetType, selectorFieldName, reflect.TypeOf((*K)(nil)).Elem(), opts) + if err != nil { + return err + } + + applier := func(cmd *cobra.Command, target any) error { + vTarget := reflect.ValueOf(target) + if vTarget.Kind() == reflect.Pointer { + vTarget = vTarget.Elem() + } + + selectedKey := extractSelectorValue[K](vTarget, selectorPath) + profile, exists := profiles[selectedKey] + if !exists { + // No profile for this selector value: nothing to default, not an error. + return nil + } + + // Scope the copy to the substruct owning the selector (e.g. "System" for + // "System.Env") so this profile can't clobber unrelated branches (e.g. "Chain") + // that just happen to be zero-valued in this profile's map entry. + scopePath := selectorPath[:len(selectorPath)-1] + leafName := selectorPath[len(selectorPath)-1] + targetScope := navigateFields(vTarget, scopePath) + profileScope := navigateFields(reflect.ValueOf(profile), scopePath) + prefix := scopePrefix(targetType, scopePath, opts) + + // The selector's own field (e.g. Chain.ID) sits inside the scoped substruct too; + // preserve the value that was actually used to pick this profile, since the + // profile's map entry usually leaves it zero (the profile is keyed by that value, + // not describing it). + leafField := targetScope.FieldByName(leafName) + selectedValue := reflect.ValueOf(leafField.Interface()) + + applyProfileDefaults(cmd, entry, opts, prefix, targetScope, profileScope) + + leafField.Set(selectedValue) + return nil + } + + entry.profiles = append(entry.profiles, applier) + enableProfileHelp(cmd, selectorPath, profiles) + return nil +} + +// findEntryByTargetType returns the single entry registered on meta whose target's type +// (dereferenced) matches targetType. +func findEntryByTargetType(meta *commandMetaData, targetType reflect.Type) (*targetEntry, error) { + var match *targetEntry + for _, e := range meta.entries { + et := reflect.TypeOf(e.target) + if et.Kind() == reflect.Pointer { + et = et.Elem() + } + if et != targetType { + continue + } + if match != nil { + return nil, fmt.Errorf("multiple registered targets of type %s on this command; ambiguous profile registration", targetType) + } + match = e + } + if match == nil { + return nil, fmt.Errorf("no registered target of type %s on this command; call RegisterCommandFlags/RegisterSubcommandFlags first", targetType) + } + return match, nil +} + +// --- INTERNAL HELPERS --- + +func verifySelectorType(tType reflect.Type, selectorFieldName string, kType reflect.Type, opts Options) ([]string, error) { + if tType.Kind() != reflect.Struct { + return nil, fmt.Errorf("target type T must be a struct") + } + + path, fType, found := findFieldByTagOrName(tType, selectorFieldName, opts) + if !found { + return nil, fmt.Errorf("field or tag %q not found in struct %s", selectorFieldName, tType.Name()) + } + + if fType != kType { + return nil, fmt.Errorf("type mismatch for %q in %s: field is %s, profile key K is %s", selectorFieldName, tType.Name(), fType, kType) + } + + return path, nil +} + +func findFieldByTagOrName(t reflect.Type, name string, opts Options) ([]string, reflect.Type, bool) { + if strings.Contains(name, ".") { + parts := strings.Split(name, ".") + curr := t + var fullPath []string + + for _, part := range parts { + if curr.Kind() == reflect.Pointer { + curr = curr.Elem() + } + if curr.Kind() != reflect.Struct { + return nil, nil, false + } + + subPath, fType, ok := findSingleField(curr, part, opts) + if !ok { + return nil, nil, false + } + fullPath = append(fullPath, subPath...) + curr = fType + } + return fullPath, curr, true + } + + return findSingleField(t, name, opts) +} + +func findSingleField(t reflect.Type, name string, opts Options) ([]string, reflect.Type, bool) { + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + if field.PkgPath != "" { + continue + } + + keyName, _ := opts.tagKey(field) + + if strings.EqualFold(field.Name, name) || strings.EqualFold(keyName, name) { + return []string{field.Name}, field.Type, true + } + + elemType := field.Type + if elemType.Kind() == reflect.Pointer { + elemType = elemType.Elem() + } + if elemType.Kind() == reflect.Struct { + if subPath, fType, ok := findSingleField(elemType, name, opts); ok { + return append([]string{field.Name}, subPath...), fType, true + } + } + } + return nil, nil, false +} + +func extractSelectorValue[K comparable](v reflect.Value, path []string) K { + curr := v + for _, p := range path { + if curr.Kind() == reflect.Pointer { + curr = curr.Elem() + } + curr = curr.FieldByName(p) + } + return curr.Interface().(K) +} + +// navigateFields walks v down through the named fields in path (dereferencing pointers +// as needed), returning the resulting nested field value. +func navigateFields(v reflect.Value, path []string) reflect.Value { + curr := v + for _, p := range path { + if curr.Kind() == reflect.Pointer { + curr = curr.Elem() + } + curr = curr.FieldByName(p) + } + return curr +} + +// scopePrefix computes the dotted toml/mapstructure key prefix (e.g. "system") that +// corresponds to the given Go field-name path (e.g. ["System"]) starting from struct type t. +func scopePrefix(t reflect.Type, path []string, opts Options) string { + var parts []string + curr := t + for _, name := range path { + if curr.Kind() == reflect.Pointer { + curr = curr.Elem() + } + field, ok := curr.FieldByName(name) + if !ok { + break + } + key, _ := opts.tagKey(field) + parts = append(parts, key) + curr = field.Type + } + return strings.Join(parts, ".") +} + +func applyProfileDefaults(cmd *cobra.Command, entry *targetEntry, opts Options, prefix string, tVal, pVal reflect.Value) { + if pVal.Kind() == reflect.Pointer { + pVal = pVal.Elem() + } + copyDefaultsRecursive(cmd, entry, opts, prefix, tVal, pVal) +} + +func copyDefaultsRecursive(cmd *cobra.Command, entry *targetEntry, opts Options, prefix string, tVal, pVal reflect.Value) { + namespace, prefixes := entry.namespace, entry.prefixes + tType := tVal.Type() + for i := 0; i < tType.NumField(); i++ { + field := tType.Field(i) + if field.PkgPath != "" { + continue + } + + key, _ := opts.tagKey(field) + + relKey := key + if prefix != "" { + relKey = prefix + "." + key + } + + viperKey := relKey + if namespace != "" { + viperKey = namespace + "." + relKey + } + + targetField := tVal.Field(i) + profileField := pVal.Field(i) + + if targetField.Kind() == reflect.Pointer { + if targetField.IsNil() && !profileField.IsNil() { + targetField.Set(reflect.New(targetField.Type().Elem())) + } + if !targetField.IsNil() && !profileField.IsNil() { + targetField = targetField.Elem() + profileField = profileField.Elem() + } else { + continue + } + } + + if targetField.Kind() == reflect.Struct { + copyDefaultsRecursive(cmd, entry, opts, relKey, targetField, profileField) + continue + } + + if !isExplicitlySet(cmd, flagNameFromViperKey(relKey), viperKey, prefixes) { + targetField.Set(profileField) + } + } +} + +// isExplicitlySet reports whether viperKey's value came from something other than its +// registered code default: a changed CLI flag, a config file entry, or a bound env var. +// flagName is passed separately because a namespaced entry's flag ("timeout") does not match +// its viper key ("foo.timeout"). +func isExplicitlySet(cmd *cobra.Command, flagName, viperKey string, prefixes []string) bool { + if f := cmd.Flags().Lookup(flagName); f != nil && f.Changed { + return true + } + + if viper.InConfig(viperKey) { + return true + } + + envSuffix := strings.ToUpper(strings.ReplaceAll(strings.ReplaceAll(viperKey, ".", "_"), "-", "_")) + for _, prefix := range prefixes { + cleanPrefix := strings.TrimSuffix(strings.ToUpper(prefix), "_") + if os.Getenv(cleanPrefix+"_"+envSuffix) != "" { + return true + } + } + + return false +} + +func flagNameFromViperKey(viperKey string) string { + parts := strings.Split(viperKey, ".") + for i, p := range parts { + parts[i] = strings.ReplaceAll(p, "_", "-") + } + return strings.Join(parts, ".") +} + +func enableProfileHelp[T any, K comparable](cmd *cobra.Command, selectorPath []string, profiles map[K]T) { + selectorFlagName := strings.ReplaceAll(strings.ToLower(selectorPath[len(selectorPath)-1]), "_", "-") + existingHelp := cmd.HelpFunc() + + cmd.SetHelpFunc(func(c *cobra.Command, args []string) { + if existingHelp != nil { + existingHelp(c, args) + } else { + fmt.Printf("Usage of %s:\n\n", c.CommandPath()) + c.Flags().VisitAll(func(f *pflag.Flag) { + defaultMsg := "" + if f.DefValue != "" { + defaultMsg = fmt.Sprintf(" (default %q)", f.DefValue) + } + fmt.Printf(" --%-35s %s%s\n", f.Name, f.Usage, defaultMsg) + }) + } + + fmt.Printf("\nAVAILABLE PROFILES FOR --%s:\n", selectorFlagName) + for k := range profiles { + fmt.Printf(" - %v\n", k) + } + }) +} + +func registerStructFlagsInternal(cmd *cobra.Command, entry *targetEntry, isSubcommand bool) error { + if entry.target == nil { + return fmt.Errorf("target cannot be nil") + } + + return walkStruct(entry.target, entry.opts, structVisitor{ + branch: func(m fieldMeta) (bool, error) { + if err := checkExclusiveStructIsPointer(m); err != nil { + return false, err + } + return false, entry.opts.checkEmbeddedIsUnnamed(m) + }, + leaf: func(m fieldMeta) error { + bindLeafFlag(cmd, entry, isSubcommand, m) + return nil + }, + }) +} + +// checkExclusiveStructIsPointer rejects a non-pointer nested struct carrying a cross-field +// rule. Such a field can never be absent - a zero struct is still a struct - so the rule can't +// distinguish "not configured" from "configured to zero", and the validator descends into it +// regardless and reports its inner `required` fields for a section the user never asked for. +// A pointer makes absence representable (nil), which is what these rules need. +func checkExclusiveStructIsPointer(m fieldMeta) error { + if m.field.Type.Kind() == reflect.Pointer || m.isTextUnmarshaler { + return nil + } + rules := crossFieldRuleNames(m.field) + if len(rules) == 0 { + return nil + } + return fmt.Errorf("%s: %s on a nested struct requires a pointer field (*%s); a value struct is never absent, so the rule cannot fire and %s's own required fields are reported even when the section is unused", + m.key(), strings.Join(rules, "/"), m.elemType.Name(), m.elemType.Name()) +} + +func bindLeafFlag(cmd *cobra.Command, entry *targetEntry, isSubcommand bool, m fieldMeta) { + namespace := entry.namespace + + relKey := m.key() + viperKey := relKey + if namespace != "" { + viperKey = namespace + "." + relKey + } + + flagKey := relKey + if entry.flagPrefix != "" { + flagKey = entry.flagPrefix + "." + relKey + } + flagName := flagNameFromViperKey(flagKey) + entry.keys = append(entry.keys, leafKey{relPath: m.keyPath, viperKey: viperKey, flagName: flagName}) + defaultVal := m.elem.Interface() + usageMsg := m.field.Tag.Get("usage") + + flags := cmd.Flags() + if !isSubcommand { + flags = cmd.PersistentFlags() + } + + switch { + case m.elemType == configDurationType: + d := defaultVal.(config.Duration) + // Default is stored as its text form so that every source (flag, env, config file, + // default) reaches the decoder as a string and goes through UnmarshalText. Handing + // mapstructure the config.Duration struct instead would decode struct-to-struct and + // silently drop the value, since its only field is unexported. + viper.SetDefault(viperKey, d.String()) + flags.Duration(flagName, d.Duration(), usageMsg) + case m.isTextUnmarshaler: + text := fmt.Sprintf("%v", defaultVal) + viper.SetDefault(viperKey, text) + flags.String(flagName, text, usageMsg) + case m.elemType == durationType: + viper.SetDefault(viperKey, defaultVal) + flags.Duration(flagName, time.Duration(m.elem.Int()), usageMsg) + case m.elemType.Kind() == reflect.Slice && m.elemType.Elem().Kind() == reflect.String: + var def []string + if !m.elem.IsNil() { + def = m.elem.Interface().([]string) + } + viper.SetDefault(viperKey, def) + flags.StringSlice(flagName, def, usageMsg) + default: + viper.SetDefault(viperKey, defaultVal) + switch m.elemType.Kind() { + case reflect.String: + flags.String(flagName, m.elem.String(), usageMsg) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + flags.Int64(flagName, m.elem.Int(), usageMsg) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + flags.Uint64(flagName, m.elem.Uint(), usageMsg) + case reflect.Bool: + flags.Bool(flagName, m.elem.Bool(), usageMsg) + default: + flags.String(flagName, fmt.Sprintf("%v", defaultVal), usageMsg) + } + } + + _ = viper.BindPFlag(viperKey, flags.Lookup(flagName)) + // Env vars are bound later, by entry.bindEnv at decode time - see effectivePrefixes. +} diff --git a/pkg/config/flags/flags_test.go b/pkg/config/flags/flags_test.go new file mode 100644 index 0000000000..304b5ad8d3 --- /dev/null +++ b/pkg/config/flags/flags_test.go @@ -0,0 +1,1426 @@ +package flags + +import ( + "io" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-common/pkg/config" + "github.com/smartcontractkit/chainlink-common/pkg/config/configdoc" +) + +// newRoot returns a root command wired for testing: its own viper, a --config flag, and +// silenced output. Global state (the viper singleton and the load-config-once guard) is +// restored on cleanup so tests can't leak keys or a consumed once into each other. +func newRoot(t *testing.T) *cobra.Command { + t.Helper() + + oldViper := viper.GetViper() + viper.Reset() + t.Cleanup(func() { *viper.GetViper() = *oldViper }) + + oldOnce, oldErr := configFileOnce, configFileErr + configFileOnce, configFileErr = new(sync.Once), nil + t.Cleanup(func() { configFileOnce, configFileErr = oldOnce, oldErr }) + + cmd := &cobra.Command{Use: "app", RunE: func(*cobra.Command, []string) error { return nil }} + cmd.PersistentFlags().String("config", "", "path to config file") + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + return cmd +} + +// run registers target on a fresh root command and executes it with args, returning the error +// from the decode/validate step (nil if the config was accepted). +func run(t *testing.T, target any, args ...string) error { + t.Helper() + + root := newRoot(t) + require.NoError(t, RegisterCommandFlags(root, target, DefaultTOMLOptions("TEST"))) + root.SetArgs(args) + return root.Execute() +} + +// writeConfig writes a TOML config file and returns its path, for passing via --config. +func writeConfig(t *testing.T, contents string) string { + t.Helper() + + path := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, os.WriteFile(path, []byte(contents), 0o600)) + return path +} + +func TestRequiredLeaf(t *testing.T) { + type cfg struct { + Name string `toml:"name" validate:"required"` + } + + t.Run("missing", func(t *testing.T) { + var c cfg + require.ErrorContains(t, run(t, &c), "'required'") + }) + + t.Run("provided", func(t *testing.T) { + var c cfg + require.NoError(t, run(t, &c, "--name", "x")) + assert.Equal(t, "x", c.Name) + }) +} + +func TestUntaggedFieldUsesItsGoNameInKebabCase(t *testing.T) { + type inner struct { + PollInterval config.Duration + } + type cfg struct { + URL string + ChainID uint32 + UseRealDBForFake bool + Chain inner + } + + // No tags at all: the key, the flag and the env var all come from the field name, so a struct + // only carries a tag where its key differs from it. + var c cfg + require.NoError(t, run(t, &c, + "--url", "postgres://x", + "--chain-id", "137", + "--use-real-db-for-fake", + "--chain.poll-interval", "7s", + )) + assert.Equal(t, "postgres://x", c.URL) + assert.Equal(t, uint32(137), c.ChainID) + assert.True(t, c.UseRealDBForFake) + assert.Equal(t, 7*time.Second, c.Chain.PollInterval.Duration()) +} + +func TestUntaggedFieldReadsItsEnvVar(t *testing.T) { + type cfg struct { + FinalityTagEnabled bool + } + + t.Setenv("TEST_FINALITY_TAG_ENABLED", "true") + + var c cfg + require.NoError(t, run(t, &c)) + assert.True(t, c.FinalityTagEnabled) +} + +func TestTagWinsOverTheGoName(t *testing.T) { + type cfg struct { + // The plural field is bound to a singular key, which the field name cannot produce. + HTTPURLs []string `toml:"http-url"` + } + + var c cfg + require.NoError(t, run(t, &c, "--http-url", "https://one")) + assert.Equal(t, []string{"https://one"}, c.HTTPURLs) +} + +func TestNestedStructDecodes(t *testing.T) { + type inner struct { + Host string `toml:"host"` + } + type cfg struct { + Chain inner `toml:"chain"` + } + + var c cfg + require.NoError(t, run(t, &c, "--chain.host", "example.com")) + assert.Equal(t, "example.com", c.Chain.Host) +} + +func TestSquashedStructDecodes(t *testing.T) { + type inner struct { + Host string `toml:"host"` + } + type cfg struct { + // DefaultTOMLOptions names the squash option "inline", matching TOML's inline table. + Inner inner `toml:",inline"` + } + + // Squashed fields contribute no key segment, so the flag is --host, not --inner.host. + var c cfg + require.NoError(t, run(t, &c, "--host", "example.com")) + assert.Equal(t, "example.com", c.Inner.Host) +} + +func TestSquashOptionIsConfigurable(t *testing.T) { + type inner struct { + Host string `toml:"host"` + } + type cfg struct { + Inner inner `toml:",squash"` + } + + opts := DefaultTOMLOptions("TEST") + opts.DecoderConfig.SquashTagOption = "squash" + + root := newRoot(t) + var c cfg + require.NoError(t, RegisterCommandFlags(root, &c, opts)) + + root.SetArgs([]string{"--host", "example.com"}) + require.NoError(t, root.Execute()) + assert.Equal(t, "example.com", c.Inner.Host) +} + +// Exported so the embedded field itself is exported (an embedded unexported type is an +// unexported field, which the walker skips). +type EmbeddedInner struct { + Host string `toml:"host" mapstructure:"host"` +} + +func TestEmbeddedStructIsSquashedWhenDecoderSquashes(t *testing.T) { + type cfg struct { + EmbeddedInner // no tag; DefaultTOMLOptions sets DecoderConfig.Squash + } + + var c cfg + require.NoError(t, run(t, &c, "--host", "example.com")) + assert.Equal(t, "example.com", c.Host) +} + +func TestEmbeddedStructIsNestedWhenDecoderDoesNot(t *testing.T) { + type cfg struct { + EmbeddedInner + } + + // Squash off: mapstructure treats the embedded struct as a field named after its type, + // so the flag must be namespaced to match, not flattened to --host. + opts := DefaultTOMLOptions("TEST") + opts.DecoderConfig.Squash = false + + root := newRoot(t) + var c cfg + require.NoError(t, RegisterCommandFlags(root, &c, opts)) + + root.SetArgs([]string{"--embedded-inner.host", "example.com"}) + require.NoError(t, root.Execute()) + assert.Equal(t, "example.com", c.Host) +} + +func TestNamedEmbeddedStructIsRejectedWhenSquashing(t *testing.T) { + type cfg struct { + // The name is a lie under squashing: these fields flatten into the parent, but + // encoding/json would nest them under "inner". + EmbeddedInner `toml:"inner"` + } + + err := RegisterCommandFlags(newRoot(t), &cfg{}, DefaultTOMLOptions("TEST")) + require.Error(t, err) + assert.Contains(t, err.Error(), "must not be named") +} + +func TestNamedEmbeddedStructIsAllowedWhenNotSquashing(t *testing.T) { + type cfg struct { + EmbeddedInner `toml:"inner"` + } + + // Squash off, so the name is honoured rather than ignored - and agrees with json. + opts := DefaultTOMLOptions("TEST") + opts.DecoderConfig.Squash = false + + root := newRoot(t) + var c cfg + require.NoError(t, RegisterCommandFlags(root, &c, opts)) + + root.SetArgs([]string{"--inner.host", "example.com"}) + require.NoError(t, root.Execute()) + assert.Equal(t, "example.com", c.Host) +} + +func TestUnnamedEmbeddedStructMayCarryTagOptions(t *testing.T) { + type cfg struct { + // Options without a name are fine: nothing is being contradicted. + EmbeddedInner `toml:",inline"` + } + + var c cfg + require.NoError(t, run(t, &c, "--host", "example.com")) + assert.Equal(t, "example.com", c.Host) +} + +// EmbeddedShared stands in for a config struct owned elsewhere (the package that consumes it), +// which a binary embeds by pointer to add its own settings alongside without copying it. +type EmbeddedShared struct { + Host string `toml:"host" usage:"remote host"` +} + +func TestEmbeddedPointerStructIsSquashed(t *testing.T) { + type cfg struct { + *EmbeddedShared `toml:",inline"` + + Mine string `toml:"mine" usage:"this binary's own setting"` + } + + // Non-nil, the way a caller supplies the instance the shared defaults were set on. + c := cfg{EmbeddedShared: &EmbeddedShared{}} + require.NoError(t, run(t, &c, "--host", "example.com", "--mine", "x")) + assert.Equal(t, "example.com", c.Host, "the embedded fields flatten into the parent") + assert.Equal(t, "x", c.Mine) +} + +func TestEmbeddedPointerStructIsSquashedUnderNamespace(t *testing.T) { + type cfg struct { + *EmbeddedShared `toml:",inline"` + + Mine string `toml:"mine" usage:"this binary's own setting"` + } + + opts := DefaultTOMLOptions("TEST") + opts.Namespace = "remote" + + root := newRoot(t) + c := cfg{EmbeddedShared: &EmbeddedShared{}} + require.NoError(t, RegisterCommandFlags(root, &c, opts)) + + root.SetArgs([]string{"--remote.host", "example.com"}) + require.NoError(t, root.Execute()) + assert.Equal(t, "example.com", c.Host) + + // One table, not an empty "[remote]" plus a "[remote.]" holding the embedded fields. + toml, err := structToDocs(&c, opts.Namespace, opts) + require.NoError(t, err) + assert.Contains(t, toml, "[remote]") + assert.NotContains(t, toml, "[remote.]") + assert.Contains(t, toml, "host = ") + assert.Contains(t, toml, "mine = ") +} + +// A cross-field rule can only name fields of its own struct, so with settings split across an +// embedded struct the rule has to sit on the outer field - naming a promoted sibling. +func TestExcludedWithNamesPromotedSibling(t *testing.T) { + type cfg struct { + *EmbeddedShared `toml:",inline"` + + Proxy string `toml:"proxy" usage:"use a proxy instead of a host" validate:"required_without=Host,excluded_with=Host"` + } + + t.Run("neither set", func(t *testing.T) { + c := cfg{EmbeddedShared: &EmbeddedShared{}} + require.ErrorContains(t, run(t, &c), "'required_without'") + }) + + t.Run("both set", func(t *testing.T) { + c := cfg{EmbeddedShared: &EmbeddedShared{}} + require.ErrorContains(t, run(t, &c, "--host", "example.com", "--proxy", "localhost:1"), "'excluded_with'") + }) + + t.Run("only the promoted one set", func(t *testing.T) { + c := cfg{EmbeddedShared: &EmbeddedShared{}} + require.NoError(t, run(t, &c, "--host", "example.com")) + }) + + t.Run("only the outer one set", func(t *testing.T) { + c := cfg{EmbeddedShared: &EmbeddedShared{}} + require.NoError(t, run(t, &c, "--proxy", "localhost:1")) + }) + + t.Run("docs name it by its config key", func(t *testing.T) { + c := cfg{EmbeddedShared: &EmbeddedShared{}} + toml, err := structToDocs(&c, "", DefaultTOMLOptions("TEST")) + require.NoError(t, err) + assert.Contains(t, toml, "must not be set when host is set") + }) + + t.Run("example config picks one", func(t *testing.T) { + c := cfg{EmbeddedShared: &EmbeddedShared{}} + example, err := exampleDoc(&c, "", DefaultTOMLOptions("TEST")) + require.NoError(t, err) + // The promoted field is declared first and wins, even though the rule ruling the other + // one out lives in a different struct. + assert.Contains(t, example, "host = ") + assert.NotContains(t, example, "proxy = ") + }) +} + +func TestTagNameIsConfigurable(t *testing.T) { + type cfg struct { + Host string `mapstructure:"host"` + } + + // The zero Options falls back to mapstructure's own tag name and squash option. + root := newRoot(t) + var c cfg + require.NoError(t, RegisterCommandFlags(root, &c, Options{Prefixes: []string{"TEST"}})) + + root.SetArgs([]string{"--host", "example.com"}) + require.NoError(t, root.Execute()) + assert.Equal(t, "example.com", c.Host) +} + +func TestOptionalNestedStructStaysNil(t *testing.T) { + type inner struct { + Host string `toml:"host" validate:"required"` + } + type cfg struct { + // Nothing under it was supplied, so it must stay nil rather than being allocated + // with defaults - otherwise its inner `required` would fire for an absent section. + Inner *inner `toml:"inner"` + } + + var c cfg + require.NoError(t, run(t, &c)) + assert.Nil(t, c.Inner) +} + +func TestOptionalNestedStructAllocatedWhenSet(t *testing.T) { + type inner struct { + Host string `toml:"host" validate:"required"` + } + type cfg struct { + Inner *inner `toml:"inner"` + } + + var c cfg + require.NoError(t, run(t, &c, "--inner.host", "example.com")) + require.NotNil(t, c.Inner) + assert.Equal(t, "example.com", c.Inner.Host) +} + +// The exactly-one-of shape: two mutually exclusive nested sections, each required when the +// other is absent. Pointers make "absent" representable, which is what stops the unselected +// section's own `required` fields from being reported. +type modeB struct { + X int32 `toml:"x" validate:"required"` + Z int32 `toml:"z" validate:"required"` + W int32 `toml:"w"` // deliberately not required +} + +type modeC struct { + Y int32 `toml:"y"` + Q int32 `toml:"q" validate:"required"` +} + +type modesCfg struct { + B *modeB `toml:"b" validate:"required_without=C,excluded_with=C"` + C *modeC `toml:"c" validate:"required_without=B,excluded_with=B"` +} + +func TestExclusiveModes_NeitherSet(t *testing.T) { + var c modesCfg + err := run(t, &c) + require.Error(t, err) + assert.Contains(t, err.Error(), "'required_without'") +} + +func TestExclusiveModes_OnlyBSet(t *testing.T) { + var c modesCfg + // C is absent, so none of C's own required fields should be reported. + require.NoError(t, run(t, &c, "--b.x", "1", "--b.z", "2")) + require.NotNil(t, c.B) + assert.Nil(t, c.C) + assert.Equal(t, int32(1), c.B.X) +} + +func TestExclusiveModes_OnlyCSet(t *testing.T) { + var c modesCfg + require.NoError(t, run(t, &c, "--c.q", "5")) + require.NotNil(t, c.C) + assert.Nil(t, c.B) +} + +func TestExclusiveModes_BothSetIsRejected(t *testing.T) { + var c modesCfg + err := run(t, &c, "--b.x", "1", "--b.z", "2", "--c.q", "5") + require.Error(t, err) + assert.Contains(t, err.Error(), "'excluded_with'") +} + +func TestExclusiveModes_PartialSectionReportsItsOwnRequired(t *testing.T) { + var c modesCfg + // B is present but incomplete: its missing required field is what should be reported. + err := run(t, &c, "--b.x", "1") + require.Error(t, err) + assert.Contains(t, err.Error(), "'Z'") +} + +func TestExclusiveModes_OnlyNonRequiredFieldSet(t *testing.T) { + var c modesCfg + // Setting only W allocates B, so B's unset required fields are reported. + err := run(t, &c, "--b.w", "9") + require.Error(t, err) + assert.Contains(t, err.Error(), "'X'") + assert.Contains(t, err.Error(), "'Z'") +} + +func TestValueStructWithCrossFieldRuleIsRejected(t *testing.T) { + type inner struct { + Host string `toml:"host" validate:"required"` + } + type other struct { + Addr string `toml:"addr"` + } + type cfg struct { + // Not a pointer, so it can never be absent - registration should refuse it rather + // than silently mis-validating at run time. + A inner `toml:"a" validate:"excluded_with=B"` + B other `toml:"b"` + } + + cmd := &cobra.Command{Use: "app"} + err := RegisterCommandFlags(cmd, &cfg{}, DefaultTOMLOptions()) + require.Error(t, err) + assert.Contains(t, err.Error(), "requires a pointer field") +} + +func TestExcludedWithoutLeaf(t *testing.T) { + type cfg struct { + URL string `toml:"url"` + RealDB bool `toml:"real-db" validate:"excluded_without=URL"` + } + + t.Run("set without its dependency", func(t *testing.T) { + var c cfg + require.ErrorContains(t, run(t, &c, "--real-db"), "'excluded_without'") + }) + + t.Run("set with its dependency", func(t *testing.T) { + var c cfg + require.NoError(t, run(t, &c, "--url", "postgres://x", "--real-db")) + }) +} + +func TestRequiredWithLeaf(t *testing.T) { + type cfg struct { + Enable bool `toml:"enable"` + Token string `toml:"token" validate:"required_with=Enable"` + } + + t.Run("dependency set, field missing", func(t *testing.T) { + var c cfg + require.ErrorContains(t, run(t, &c, "--enable"), "'required_with'") + }) + + t.Run("dependency unset", func(t *testing.T) { + var c cfg + require.NoError(t, run(t, &c)) + }) +} + +// --- precedence: flag > env > config file > compiled-in default --- + +type precedenceCfg struct { + Value string `toml:"value"` +} + +func newPrecedenceCfg() *precedenceCfg { return &precedenceCfg{Value: "from-default"} } + +func TestPrecedence_DefaultWhenNothingSet(t *testing.T) { + c := newPrecedenceCfg() + require.NoError(t, run(t, c)) + assert.Equal(t, "from-default", c.Value) +} + +func TestPrecedence_ConfigFileBeatsDefault(t *testing.T) { + path := writeConfig(t, "value = 'from-file'\n") + + c := newPrecedenceCfg() + require.NoError(t, run(t, c, "--config", path)) + assert.Equal(t, "from-file", c.Value) +} + +func TestPrecedence_EnvBeatsConfigFile(t *testing.T) { + path := writeConfig(t, "value = 'from-file'\n") + t.Setenv("TEST_VALUE", "from-env") + + c := newPrecedenceCfg() + require.NoError(t, run(t, c, "--config", path)) + assert.Equal(t, "from-env", c.Value) +} + +func TestPrecedence_FlagBeatsEnv(t *testing.T) { + path := writeConfig(t, "value = 'from-file'\n") + t.Setenv("TEST_VALUE", "from-env") + + c := newPrecedenceCfg() + require.NoError(t, run(t, c, "--config", path, "--value", "from-flag")) + assert.Equal(t, "from-flag", c.Value) +} + +func TestEnvPrefixesTriedInOrder(t *testing.T) { + type cfg struct { + Value string `toml:"value"` + } + + root := newRoot(t) + var c cfg + require.NoError(t, RegisterCommandFlags(root, &c, DefaultTOMLOptions("FIRST", "SECOND"))) + + // Both are bound; the earlier prefix wins. + t.Setenv("FIRST_VALUE", "first") + t.Setenv("SECOND_VALUE", "second") + + root.SetArgs(nil) + require.NoError(t, root.Execute()) + assert.Equal(t, "first", c.Value) +} + +// --- subcommands --- + +// newSubcommand registers rootTarget on a root and subTarget under namespace on a child +// command, returning both so a test can execute the child. +func newSubcommand(t *testing.T, rootTarget, subTarget any, namespace string) *cobra.Command { + t.Helper() + + root := newRoot(t) + require.NoError(t, RegisterCommandFlags(root, rootTarget, DefaultTOMLOptions("TEST"))) + + sub := &cobra.Command{Use: namespace, RunE: func(*cobra.Command, []string) error { return nil }} + require.NoError(t, RegisterSubcommandFlags(sub, namespace, subTarget, DefaultTOMLOptions())) + root.AddCommand(sub) + return root +} + +func TestSubcommand_FlagIsNotNamespaced(t *testing.T) { + type rootCfg struct { + Host string `toml:"host"` + } + type subCfg struct { + Retries int `toml:"retries"` + } + + var r rootCfg + var s subCfg + root := newSubcommand(t, &r, &s, "sub") + + // The viper key is "sub.retries", but the flag stays --retries. + root.SetArgs([]string{"sub", "--retries", "9"}) + require.NoError(t, root.Execute()) + assert.Equal(t, 9, s.Retries) +} + +func TestSubcommand_EnvIsNamespaced(t *testing.T) { + type rootCfg struct { + Host string `toml:"host"` + } + type subCfg struct { + Retries int `toml:"retries"` + } + + var r rootCfg + var s subCfg + root := newSubcommand(t, &r, &s, "sub") + + // Namespace appears in the env var (and the root's prefix is inherited). + t.Setenv("TEST_SUB_RETRIES", "11") + + root.SetArgs([]string{"sub"}) + require.NoError(t, root.Execute()) + assert.Equal(t, 11, s.Retries) +} + +func TestSubcommand_ConfigFileUsesNamespacedTable(t *testing.T) { + type rootCfg struct { + Host string `toml:"host"` + } + type subCfg struct { + Retries int `toml:"retries"` + } + + var r rootCfg + var s subCfg + root := newSubcommand(t, &r, &s, "sub") + path := writeConfig(t, "host = 'example.com'\n\n[sub]\nretries = 4\n") + + root.SetArgs([]string{"sub", "--config", path}) + require.NoError(t, root.Execute()) + assert.Equal(t, 4, s.Retries) + assert.Equal(t, "example.com", r.Host, "root config decodes too when a subcommand runs") +} + +func TestSubcommand_RootValidationStillApplies(t *testing.T) { + type rootCfg struct { + Host string `toml:"host" validate:"required"` + } + type subCfg struct { + Retries int `toml:"retries"` + } + + var r rootCfg + var s subCfg + root := newSubcommand(t, &r, &s, "sub") + + root.SetArgs([]string{"sub"}) + require.ErrorContains(t, root.Execute(), "'required'") +} + +// --- hook chaining --- + +func TestCallersPreRunESeesDecodedConfig(t *testing.T) { + type cfg struct { + Host string `toml:"host"` + } + + root := newRoot(t) + var c cfg + var seen string + root.PreRunE = func(*cobra.Command, []string) error { + seen = c.Host // must already be populated + return nil + } + require.NoError(t, RegisterCommandFlags(root, &c, DefaultTOMLOptions("TEST"))) + + root.SetArgs([]string{"--host", "example.com"}) + require.NoError(t, root.Execute()) + assert.Equal(t, "example.com", seen) +} + +func TestCallersPersistentPreRunEStillRuns(t *testing.T) { + type cfg struct { + Host string `toml:"host"` + } + + root := newRoot(t) + var c cfg + called := false + root.PersistentPreRunE = func(*cobra.Command, []string) error { + called = true + return nil + } + require.NoError(t, RegisterCommandFlags(root, &c, DefaultTOMLOptions("TEST"))) + + root.SetArgs(nil) + require.NoError(t, root.Execute()) + assert.True(t, called) +} + +// --- several independent targets on one command --- + +func TestMultipleTargetsDecodeIndependently(t *testing.T) { + type dbCfg struct { + URL string `toml:"db-url" validate:"required"` + } + type evmCfg struct { + ChainID string `toml:"evm-chain-id" validate:"required"` + } + + root := newRoot(t) + var db dbCfg + var evm evmCfg + require.NoError(t, RegisterCommandFlags(root, &db, DefaultTOMLOptions("TEST"))) + require.NoError(t, RegisterCommandFlags(root, &evm, DefaultTOMLOptions("TEST"))) + + root.SetArgs([]string{"--db-url", "postgres://x", "--evm-chain-id", "1"}) + require.NoError(t, root.Execute()) + assert.Equal(t, "postgres://x", db.URL) + assert.Equal(t, "1", evm.ChainID) +} + +func TestMultipleTargetsBothReportTheirOwnErrors(t *testing.T) { + type dbCfg struct { + URL string `toml:"db-url" validate:"required"` + } + type evmCfg struct { + ChainID string `toml:"evm-chain-id" validate:"required"` + } + + root := newRoot(t) + require.NoError(t, RegisterCommandFlags(root, &dbCfg{}, DefaultTOMLOptions("TEST"))) + require.NoError(t, RegisterCommandFlags(root, &evmCfg{}, DefaultTOMLOptions("TEST"))) + + root.SetArgs(nil) + err := root.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "'URL'") + assert.Contains(t, err.Error(), "'ChainID'", "one target's failure must not hide the other's") +} + +// --- cross-field rules naming a nested field --- + +// validator resolves a rule's parameter from the struct carrying the rule, so a parameter may +// reach *down* into a nested struct ("Mid.Deep.Bar") but never up out of its own struct. These +// cover the reaching-down form end to end: flag, env, docs and example config. + +type deepCfg struct { + Bar string `usage:"the deep setting"` +} + +type midCfg struct { + Deep deepCfg +} + +type nestedRuleCfg struct { + Mid midCfg + Val string `usage:"needed alongside the deep setting" validate:"required_with=Mid.Deep.Bar"` +} + +func TestNestedFieldRuleFiresFromFlag(t *testing.T) { + var c nestedRuleCfg + require.ErrorContains(t, run(t, &c, "--mid.deep.bar", "x"), "'required_with'") +} + +func TestNestedFieldRuleFiresFromEnv(t *testing.T) { + t.Setenv("TEST_MID_DEEP_BAR", "x") + + var c nestedRuleCfg + require.ErrorContains(t, run(t, &c), "'required_with'") +} + +func TestNestedFieldRuleSatisfied(t *testing.T) { + var c nestedRuleCfg + require.NoError(t, run(t, &c, "--mid.deep.bar", "x", "--val", "y")) + assert.Equal(t, "x", c.Mid.Deep.Bar) + assert.Equal(t, "y", c.Val) + + // Trigger absent, so the rule doesn't fire. + var d nestedRuleCfg + require.NoError(t, run(t, &d)) +} + +func TestNestedFieldRuleUnderNamespace(t *testing.T) { + opts := DefaultTOMLOptions("TEST") + opts.Namespace = "app" + + root := newRoot(t) + var c nestedRuleCfg + require.NoError(t, RegisterCommandFlags(root, &c, opts)) + + root.SetArgs([]string{"--app.mid.deep.bar", "x"}) + require.ErrorContains(t, root.Execute(), "'required_with'") +} + +// The section form: the rule sits on the outer pointer field, since a rule inside Foo could not +// name Baz. Whether Foo's own fields are then required is Foo's business. +type sectionRuleCfg struct { + Baz deepCfg + Foo *struct { + Name string `usage:"foo's name" validate:"required"` + } `usage:"the foo section" validate:"required_with=Baz.Bar"` +} + +func TestNestedFieldRuleOnPointerSection(t *testing.T) { + t.Run("section missing", func(t *testing.T) { + var c sectionRuleCfg + require.ErrorContains(t, run(t, &c, "--baz.bar", "x"), "'required_with'") + }) + + t.Run("section present but incomplete", func(t *testing.T) { + var c sectionRuleCfg + // Naming any of the section's keys allocates it, and then its own `required` applies. + require.ErrorContains(t, run(t, &c, "--baz.bar", "x", "--foo.name", ""), "'required'") + }) + + t.Run("section complete", func(t *testing.T) { + var c sectionRuleCfg + require.NoError(t, run(t, &c, "--baz.bar", "x", "--foo.name", "n")) + require.NotNil(t, c.Foo) + assert.Equal(t, "n", c.Foo.Name) + }) +} + +func TestNestedFieldRuleIsDocumentedByItsKey(t *testing.T) { + toml, err := structToDocs(&nestedRuleCfg{}, "", DefaultTOMLOptions("TEST")) + require.NoError(t, err) + // The dotted path is reported as the config key it resolves to, not as Go field names. + assert.Contains(t, toml, "required when mid.deep.bar is set") + assert.NotContains(t, toml, "Mid.Deep.Bar") +} + +func TestNestedFieldExclusiveRuleIsResolvedInTheExample(t *testing.T) { + type cfg struct { + Mid midCfg + Val string `usage:"the alternative to the deep setting" validate:"excluded_with=Mid.Deep.Bar"` + } + + opts := DefaultTOMLOptions("TEST") + + example, err := exampleDoc(&cfg{Mid: midCfg{Deep: deepCfg{Bar: "shown"}}}, "", opts) + require.NoError(t, err) + assert.Contains(t, example, "bar = ") + assert.NotContains(t, example, "val = ", "the example must show one of the two, not both") + + // Still documented, with the rule spelled out. + toml, err := structToDocs(&cfg{}, "", opts) + require.NoError(t, err) + assert.Contains(t, toml, "must not be set when mid.deep.bar is set") +} + +// --- profiles --- + +type profileChain struct { + ID uint32 `toml:"id"` + RPC string `toml:"rpc"` +} + +type profileCfg struct { + Chain profileChain `toml:"chain"` +} + +// runWithProfile registers c plus a profile map keyed on Chain.ID, then executes with args. +func runWithProfile(t *testing.T, c *profileCfg, args ...string) error { + t.Helper() + + root := newRoot(t) + require.NoError(t, RegisterCommandFlags(root, c, DefaultTOMLOptions("TEST"))) + require.NoError(t, RegisterProfile(root, "Chain.ID", map[uint32]profileCfg{ + 1: {Chain: profileChain{RPC: "https://one"}}, + 137: {Chain: profileChain{RPC: "https://one-thirty-seven"}}, + }, DefaultTOMLOptions("TEST"))) + root.SetArgs(args) + return root.Execute() +} + +func TestProfileFillsDefaultsForSelectedKey(t *testing.T) { + c := &profileCfg{Chain: profileChain{ID: 1}} + require.NoError(t, runWithProfile(t, c)) + assert.Equal(t, "https://one", c.Chain.RPC) +} + +func TestProfileFollowsSelector(t *testing.T) { + c := &profileCfg{Chain: profileChain{ID: 1}} + require.NoError(t, runWithProfile(t, c, "--chain.id", "137")) + assert.Equal(t, uint32(137), c.Chain.ID) + assert.Equal(t, "https://one-thirty-seven", c.Chain.RPC) +} + +func TestProfileDoesNotOverrideExplicitValue(t *testing.T) { + c := &profileCfg{Chain: profileChain{ID: 1}} + require.NoError(t, runWithProfile(t, c, "--chain.rpc", "https://mine")) + assert.Equal(t, "https://mine", c.Chain.RPC) +} + +func TestProfileUnknownSelectorAppliesNothing(t *testing.T) { + c := &profileCfg{Chain: profileChain{ID: 1}} + require.NoError(t, runWithProfile(t, c, "--chain.id", "999")) + assert.Empty(t, c.Chain.RPC, "no matching profile means no defaults, not an error") +} + +func TestProfileRequiresARegisteredTargetOfItsType(t *testing.T) { + root := newRoot(t) + err := RegisterProfile(root, "Chain.ID", map[uint32]profileCfg{1: {}}, DefaultTOMLOptions()) + require.ErrorContains(t, err, "no registered target") +} + +// --- slices --- + +func TestStringSliceFromRepeatedFlag(t *testing.T) { + type cfg struct { + URLs []string `toml:"url"` + } + + var c cfg + require.NoError(t, run(t, &c, "--url", "a", "--url", "b")) + assert.Equal(t, []string{"a", "b"}, c.URLs) +} + +func TestStringSliceFromCommaSeparatedEnv(t *testing.T) { + type cfg struct { + URLs []string `toml:"url"` + } + + t.Setenv("TEST_URL", "a,b") + + var c cfg + require.NoError(t, run(t, &c)) + assert.Equal(t, []string{"a", "b"}, c.URLs) +} + +// --- config.Duration --- + +func TestConfigDurationDecodes(t *testing.T) { + type cfg struct { + Timeout config.Duration `toml:"timeout"` + } + + c := cfg{Timeout: *config.MustNewDuration(5 * time.Second)} + require.NoError(t, run(t, &c, "--timeout", "45s")) + assert.Equal(t, 45*time.Second, c.Timeout.Duration()) +} + +func TestConfigDurationRejectsNegative(t *testing.T) { + type cfg struct { + Timeout config.Duration `toml:"timeout"` + } + + // pflag accepts -5s as a duration; config.Duration is what refuses it. + t.Setenv("TEST_TIMEOUT", "-5s") + + var c cfg + require.ErrorContains(t, run(t, &c), "negative") +} + +// --- generated docs --- + +func TestDocsMarksDefaultsAndRequiredFields(t *testing.T) { + type cfg struct { + Host string `toml:"host" usage:"the host" validate:"required" example:"'example.com'"` + Retries int `toml:"retries" usage:"how many times to retry"` + } + + root := newRoot(t) + require.NoError(t, RegisterCommandFlags(root, &cfg{Retries: 3}, DefaultTOMLOptions("TEST"))) + + doc, err := GenerateDocs(root) + require.NoError(t, err) + // A required field has no real default, so it's documented by example. + assert.Contains(t, doc, "host = 'example.com' # Example") + assert.Contains(t, doc, "retries = 3 # Default") +} + +func TestDocsPutsNestedStructUsageInItsTableHeader(t *testing.T) { + type chain struct { + Host string `toml:"host" usage:"the host"` + } + type cfg struct { + Chain chain `toml:"chain" usage:"which chain to talk to"` + } + + toml, err := structToDocs(&cfg{}, "", DefaultTOMLOptions()) + require.NoError(t, err) + + // The struct's description sits in its table's header, above the fields. + assert.Contains(t, toml, "# which chain to talk to\n[chain]\n") +} + +func TestDocsFoldsSquashedStructUsageIntoEnclosingHeader(t *testing.T) { + type shared struct { + Host string `toml:"host" usage:"the host"` + } + type chain struct { + Shared shared `toml:",inline" usage:"settings shared by every chain"` + Name string `toml:"name" usage:"chain name"` + } + type cfg struct { + Chain chain `toml:"chain" usage:"which chain to talk to"` + } + + toml, err := structToDocs(&cfg{}, "", DefaultTOMLOptions()) + require.NoError(t, err) + + // A squashed struct gets no table of its own, so its description joins the header of the + // table it was flattened into, after that table's own description. + assert.Contains(t, toml, "# which chain to talk to\n# settings shared by every chain\n[chain]\n") + // Its fields still belong to that table, un-prefixed. + assert.Contains(t, toml, "host = ") + assert.NotContains(t, toml, "[chain.shared]") +} + +func TestDocsKeepsTopLevelSquashedStructUsage(t *testing.T) { + type shared struct { + Host string `toml:"host" usage:"the host"` + } + type cfg struct { + Shared shared `toml:",inline" usage:"settings shared by everything"` + } + + toml, err := structToDocs(&cfg{}, "", DefaultTOMLOptions()) + require.NoError(t, err) + + // No enclosing table at the top level, so it survives as standalone prose: a comment + // block ended by a blank line, which is what keeps it off the next field's description. + assert.True(t, strings.HasPrefix(toml, "# settings shared by everything\n\n"), toml) + + // And it still renders, rather than tripping configdoc's description checks. + root := newRoot(t) + require.NoError(t, RegisterCommandFlags(root, &cfg{}, DefaultTOMLOptions("TEST"))) + doc, err := GenerateDocs(root) + require.NoError(t, err) + assert.Contains(t, doc, "settings shared by everything") +} + +func TestNamespaceRootsKeysFlagsAndEnv(t *testing.T) { + type cfg struct { + URL string `toml:"url"` + } + + opts := DefaultTOMLOptions("TEST") + opts.Namespace = "database" + + t.Run("flag", func(t *testing.T) { + root := newRoot(t) + var c cfg + require.NoError(t, RegisterCommandFlags(root, &c, opts)) + + root.SetArgs([]string{"--database.url", "postgres://x"}) + require.NoError(t, root.Execute()) + assert.Equal(t, "postgres://x", c.URL) + }) + + t.Run("env", func(t *testing.T) { + t.Setenv("TEST_DATABASE_URL", "postgres://env") + + root := newRoot(t) + var c cfg + require.NoError(t, RegisterCommandFlags(root, &c, opts)) + + root.SetArgs(nil) + require.NoError(t, root.Execute()) + assert.Equal(t, "postgres://env", c.URL) + }) + + t.Run("config file", func(t *testing.T) { + path := writeConfig(t, "[database]\nurl = 'postgres://file'\n") + + root := newRoot(t) + var c cfg + require.NoError(t, RegisterCommandFlags(root, &c, opts)) + + root.SetArgs([]string{"--config", path}) + require.NoError(t, root.Execute()) + assert.Equal(t, "postgres://file", c.URL) + }) +} + +func TestNamespaceSeparatesSameNamedFields(t *testing.T) { + type dbCfg struct { + URL string `toml:"url"` + } + type evmCfg struct { + URL string `toml:"url"` + } + + dbOpts := DefaultTOMLOptions("TEST") + dbOpts.Namespace = "database" + evmOpts := DefaultTOMLOptions("TEST") + evmOpts.Namespace = "evm" + + root := newRoot(t) + var db dbCfg + var evm evmCfg + require.NoError(t, RegisterCommandFlags(root, &db, dbOpts)) + // Same field name, different namespace: no flag collision, and each keeps its own value. + require.NoError(t, RegisterCommandFlags(root, &evm, evmOpts)) + + root.SetArgs([]string{"--database.url", "postgres://x", "--evm.url", "https://y"}) + require.NoError(t, root.Execute()) + assert.Equal(t, "postgres://x", db.URL) + assert.Equal(t, "https://y", evm.URL) +} + +func TestNamespaceGroupsDocsUnderOneTable(t *testing.T) { + type cfg struct { + URL string `toml:"url" usage:"database url"` + } + + opts := DefaultTOMLOptions("TEST") + opts.Namespace = "database" + + toml, err := structToDocs(&cfg{}, opts.Namespace, opts) + require.NoError(t, err) + assert.Contains(t, toml, "[database]") + assert.Contains(t, toml, "url = ") +} + +func TestFlagDocsNoExampleOmitsFromExampleButKeepsDocs(t *testing.T) { + type cfg struct { + URL string `toml:"url" usage:"database url"` + RealDB bool `toml:"real-db" usage:"use a real database in fake mode" flagdocs:"noexample"` + } + + opts := DefaultTOMLOptions("TEST") + + example, err := exampleDoc(&cfg{}, "", opts) + require.NoError(t, err) + assert.NotContains(t, example, "real-db", "the example must not carry it") + assert.Contains(t, example, "url = ") + + docs, err := structToDocs(&cfg{}, "", opts) + require.NoError(t, err) + // Still documented, but marked so it is kept out of its table's code block too - every + // example in the document then describes the same working configuration. + assert.Contains(t, docs, "real-db = false "+configdoc.FieldDocsOnly) +} + +func TestDocsOnlyFieldIsAbsentFromItsTableCodeBlock(t *testing.T) { + type inner struct { + URL string `toml:"url" usage:"database url"` + RealDB bool `toml:"real-db" usage:"use a real database in fake mode" flagdocs:"noexample"` + } + type cfg struct { + Database inner `toml:"database"` + } + + root := newRoot(t) + require.NoError(t, RegisterCommandFlags(root, &cfg{}, DefaultTOMLOptions("TEST"))) + + doc, err := GenerateDocs(root) + require.NoError(t, err) + + // The table's own code block lists only what an example would contain... + section := doc[strings.Index(doc, "## database"):] + block := section[:strings.Index(section, "###")] + assert.Contains(t, block, "url = ") + assert.NotContains(t, block, "real-db") + + // ...while the field still gets its own entry further down. + assert.Contains(t, doc, "### real-db") +} + +func TestFlagDocsExampleOverridesTheValueShown(t *testing.T) { + type cfg struct { + Workers int `toml:"workers" usage:"how many workers" flagdocs:"example=8"` + } + + opts := DefaultTOMLOptions("TEST") + + example, err := exampleDoc(&cfg{Workers: 1}, "", opts) + require.NoError(t, err) + assert.Contains(t, example, "workers = 8", "the example shows the override") + + docs, err := structToDocs(&cfg{Workers: 1}, "", opts) + require.NoError(t, err) + assert.Contains(t, docs, "workers = 1 # Default", "the docs still show the real default") +} + +func TestExampleShowsOnlyTheFirstOfMutuallyExclusiveFields(t *testing.T) { + type cfg struct { + Listen []string `toml:"listen-addresses" usage:"listen here" validate:"required_without=Proxy,excluded_with=Proxy"` + Proxy string `toml:"proxy-address" usage:"or proxy there" validate:"excluded_with=Listen"` + } + + opts := DefaultTOMLOptions("TEST") + + example, err := exampleDoc(&cfg{}, "", opts) + require.NoError(t, err) + // Listing both would be a config that fails its own validation, so the example commits + // to the first. + assert.Contains(t, example, "listen-addresses = ") + assert.NotContains(t, example, "proxy-address = ") + + // Both are still documented; only the example has to choose. + docs, err := structToDocs(&cfg{}, "", opts) + require.NoError(t, err) + assert.Contains(t, docs, "listen-addresses = ") + assert.Contains(t, docs, "proxy-address = ") +} + +func TestExampleShowsOnlyTheFirstOfMutuallyExclusiveSections(t *testing.T) { + var c modesCfg + + example, err := exampleDoc(&c, "", DefaultTOMLOptions("TEST")) + require.NoError(t, err) + assert.Contains(t, example, "[b]") + assert.NotContains(t, example, "[c]", "the whole excluded section is dropped, not just its fields") +} + +func TestDocsTreatsExampleTagAsHavingNoDefault(t *testing.T) { + type cfg struct { + // Required by a rule this struct can't express, so it says so with an example. + URL string `toml:"url" usage:"database url" example:"'postgres://localhost/db'"` + } + + root := newRoot(t) + require.NoError(t, RegisterCommandFlags(root, &cfg{}, DefaultTOMLOptions("TEST"))) + + doc, err := GenerateDocs(root) + require.NoError(t, err) + assert.Contains(t, doc, "url = 'postgres://localhost/db' # Example") +} + +func TestDocsExplainsCrossFieldRulesUsingConfigKeys(t *testing.T) { + type cfg struct { + URL string `toml:"database-url" usage:"database url"` + RealDB bool `toml:"real-db" usage:"use a real db" validate:"excluded_without=URL"` + } + + root := newRoot(t) + require.NoError(t, RegisterCommandFlags(root, &cfg{}, DefaultTOMLOptions("TEST"))) + + doc, err := GenerateDocs(root) + require.NoError(t, err) + // Named by config key ("database-url"), not Go field name ("URL"). + assert.Contains(t, doc, "must not be set unless database-url is set") +} + +func TestDocsCoversSubcommands(t *testing.T) { + type rootCfg struct { + Host string `toml:"host" usage:"the host"` + } + type subCfg struct { + Retries int `toml:"retries" usage:"how many times to retry"` + } + + root := newSubcommand(t, &rootCfg{}, &subCfg{Retries: 3}, "sub") + + doc, err := GenerateDocs(root) + require.NoError(t, err) + assert.Contains(t, doc, "Global Configuration") + assert.Contains(t, doc, "Command: app sub") + assert.Contains(t, doc, "retries = 3 # Default", "subcommand fields are documented too") +} + +// spyFormat is configdoc.TOML with the value syntax changed, to prove the document really is +// assembled and rendered through Options.Format rather than hard-coded TOML. +type spyFormat struct { + configdoc.TOML +} + +func (f spyFormat) Field(key, value, marker string) string { + if marker == "" { + return key + ": " + value + } + return key + ": " + value + " " + marker +} + +// A format's two sides have to agree: whatever Field writes, ParseLine has to read back. +func (f spyFormat) ParseLine(line string) configdoc.Line { + l := f.TOML.ParseLine(line) + if l.Kind == configdoc.LineField { + l.Text = strings.TrimSuffix(l.Text, ":") + } + return l +} + +func TestDocsAssemblesWithFormatFromOptions(t *testing.T) { + type cfg struct { + Host string `toml:"host" usage:"the host"` + } + + opts := DefaultTOMLOptions("TEST") + opts.Format = spyFormat{} + + toml, err := structToDocs(&cfg{Host: "example.com"}, "", opts) + require.NoError(t, err) + // The format's Field syntax is used, not TOML's "key = value". + assert.Contains(t, toml, "host: 'example.com' # Default") +} + +func TestDocsCommandUsesFormatFromOptions(t *testing.T) { + type cfg struct { + Host string `toml:"host" usage:"the host"` + } + + opts := DefaultTOMLOptions("TEST") + opts.Format = spyFormat{} + + root := newRoot(t) + require.NoError(t, RegisterCommandFlags(root, &cfg{}, opts)) + + dir := t.TempDir() + t.Chdir(dir) + + // The format must reach the auto-added docs command too, not just GenerateDocs. + root.SetArgs([]string{"docs"}) + require.NoError(t, root.Execute()) + + written, err := os.ReadFile(filepath.Join(dir, docsOutputPath)) + require.NoError(t, err) + assert.Contains(t, string(written), "host: ") +} + +func TestBuiltinCommandsSkipValidation(t *testing.T) { + type cfg struct { + Host string `toml:"host" validate:"required"` + } + + // Reading the help that explains a required setting must not require that setting. docs + // is included because it is the same kind of command - it describes the config rather + // than consuming it - even though it opts out by its own route. + for _, args := range [][]string{ + {"help"}, + {"help", "docs"}, + {"docs"}, + {"completion", "bash"}, + {"__complete", ""}, + } { + t.Run(strings.Join(args, " "), func(t *testing.T) { + t.Chdir(t.TempDir()) // docs writes a file + + root := newRoot(t) + require.NoError(t, RegisterCommandFlags(root, &cfg{}, DefaultTOMLOptions("TEST"))) + + root.SetArgs(args) + require.NoError(t, root.Execute()) + }) + } +} + +func TestIsBuiltinCommandCoversCobrasOwnCommands(t *testing.T) { + // Callers chaining their own PersistentPreRunE need this to guard checks the library + // can't see; a chained check that misses one of these rejects `help` on a machine with no + // configuration, which is exactly what the skip exists to prevent. + root := newRoot(t) + require.NoError(t, RegisterCommandFlags(root, &struct{}{}, DefaultTOMLOptions("TEST"))) + root.InitDefaultHelpCmd() + root.InitDefaultCompletionCmd() + + byName := map[string]*cobra.Command{} + for _, c := range root.Commands() { + byName[c.Name()] = c + } + + for _, name := range []string{"help", "completion", "docs"} { + sub, ok := byName[name] + require.True(t, ok, "expected a %q command", name) + if name == "docs" { + // docs is ours: it opts out via its own PersistentPreRunE, not this check. + assert.False(t, IsBuiltinCommand(sub)) + continue + } + assert.True(t, IsBuiltinCommand(sub), name) + } + + assert.False(t, IsBuiltinCommand(root), "the root command itself runs the program") +} + +func TestNonBuiltinCommandStillValidates(t *testing.T) { + type cfg struct { + Host string `toml:"host" validate:"required"` + } + + root := newRoot(t) + require.NoError(t, RegisterCommandFlags(root, &cfg{}, DefaultTOMLOptions("TEST"))) + + root.SetArgs(nil) + require.ErrorContains(t, root.Execute(), "'required'") +} + +func TestDocsCommandIsAddedOnce(t *testing.T) { + type aCfg struct { + A string `toml:"a"` + } + type bCfg struct { + B string `toml:"b"` + } + + root := newRoot(t) + require.NoError(t, RegisterCommandFlags(root, &aCfg{}, DefaultTOMLOptions("TEST"))) + require.NoError(t, RegisterCommandFlags(root, &bCfg{}, DefaultTOMLOptions("TEST"))) + + var docsCmds int + for _, c := range root.Commands() { + if c.Name() == "docs" { + docsCmds++ + } + } + assert.Equal(t, 1, docsCmds) +} + +func TestDocsCommandSkipsValidation(t *testing.T) { + type cfg struct { + Host string `toml:"host" validate:"required"` + } + + root := newRoot(t) + require.NoError(t, RegisterCommandFlags(root, &cfg{}, DefaultTOMLOptions("TEST"))) + + // Writing docs must not require a valid config. + dir := t.TempDir() + t.Chdir(dir) + + root.SetArgs([]string{"docs"}) + require.NoError(t, root.Execute()) + assert.FileExists(t, filepath.Join(dir, docsOutputPath)) +} + +func TestUnsetFieldKeepsCallerDefault(t *testing.T) { + type cfg struct { + Retries int `toml:"retries"` + Host string `toml:"host"` + } + + c := cfg{Retries: 3, Host: "default-host"} + require.NoError(t, run(t, &c, "--host", "override")) + assert.Equal(t, 3, c.Retries, "untouched field should keep its compiled-in default") + assert.Equal(t, "override", c.Host) +} diff --git a/pkg/config/flags/options.go b/pkg/config/flags/options.go new file mode 100644 index 0000000000..a9a137d73c --- /dev/null +++ b/pkg/config/flags/options.go @@ -0,0 +1,145 @@ +package flags + +import ( + "fmt" + "reflect" + "strings" + + "github.com/go-viper/mapstructure/v2" + + "github.com/smartcontractkit/chainlink-common/pkg/config/configdoc" +) + +// Options configures how a target struct is bound, decoded, and documented. Use +// DefaultTOMLOptions for the standard `toml`-tagged setup; the zero value works too, falling +// back to mapstructure's own defaults. +type Options struct { + // Namespace roots every key of the registered struct under it, so a dependency's + // settings sit together (e.g. Namespace "database" gives the key database.url, the flag + // --database.url and the env var PREFIX_DATABASE_URL). Empty leaves the struct at the top + // level. Independent structs sharing one command should each take a namespace, both to + // group their settings and to keep same-named fields from colliding. + Namespace string + + // Prefixes are the env var prefixes a key is bound under, tried in order - e.g. "CRE" + // and "CL" bind chain.id to CRE_CHAIN_ID then CL_CHAIN_ID. Subcommands inherit the root + // command's prefixes when they specify none. + Prefixes []string + + // DecoderConfig controls how resolved values are decoded into the target. TagName, + // SquashTagOption and Squash also determine the config key (and therefore the flag name) + // of each field, so the flags, env vars, docs, and decoding all follow from this one + // setting and cannot disagree. Result is filled in per target and must be left unset. + DecoderConfig mapstructure.DecoderConfig + + // Format is the configuration file syntax the docs are written in: it renders the + // comments, tables, fields and value literals that the generated document is assembled + // from, and then renders the document itself. Defaults to configdoc.TOML. + Format configdoc.Format +} + +// DefaultTOMLOptions meant to be used with github.com/pelletier/go-toml/v2, used by default by viper for toml +// returns Options for structs tagged `toml:"key"`, with `,inline` marking a +// squashed (flattened) struct and embedded structs squashed automatically, decoded leniently +// enough for the string-typed values that env vars and pflag hand back, and documented with +// configdoc.Generate. +func DefaultTOMLOptions(prefixes ...string) Options { + return Options{ + Prefixes: prefixes, + DecoderConfig: mapstructure.DecoderConfig{ + TagName: "toml", + SquashTagOption: "inline", + // Embedded structs are flattened into the parent rather than becoming a table + // named after their type. + Squash: true, + // Env vars are always strings, and pflag hands back several types (uint64, + // duration, ...) as strings too, so the decoder has to coerce rather than + // demand exact types. Mirrors viper's own defaultDecoderConfig. + WeaklyTypedInput: true, + DecodeHook: mapstructure.ComposeDecodeHookFunc( + mapstructure.StringToTimeDurationHookFunc(), + // An env var arrives as a single string even when the field is a slice, so + // split it the way pflag splits a comma-separated StringSlice flag. + mapstructure.StringToSliceHookFunc(","), + mapstructure.TextUnmarshallerHookFunc(), + ), + }, + Format: configdoc.TOML{}, + } +} + +// tagName is the struct tag holding config keys, defaulting to mapstructure's own. +func (o Options) tagName() string { + if o.DecoderConfig.TagName == "" { + return "mapstructure" + } + return o.DecoderConfig.TagName +} + +// squashOption is the tag option marking a squashed struct, defaulting to mapstructure's own. +func (o Options) squashOption() string { + if o.DecoderConfig.SquashTagOption == "" { + return "squash" + } + return o.DecoderConfig.SquashTagOption +} + +// checkEmbeddedIsUnnamed rejects a name on an embedded struct that the decoder will squash. +// +// With squashing on, `toml:"foo"` on an embedded struct is dead text: mapstructure flattens the +// fields into the parent and the name is never used, so the config key, the flag, and the docs +// all ignore it. Other encoders do not agree - encoding/json would nest the same struct under +// "foo" - so the one struct would describe two different layouts depending on who read it. +// Rejecting the name up front keeps the tag honest; drop it to squash, or make the field named +// (non-embedded) to nest. +func (o Options) checkEmbeddedIsUnnamed(m fieldMeta) error { + if !o.DecoderConfig.Squash || !m.field.Anonymous || m.field.Type.Kind() != reflect.Struct { + return nil + } + if !o.hasExplicitName(m.field) { + return nil + } + return fmt.Errorf("%s: embedded struct %s must not be named by its %q tag while DecoderConfig.Squash is set; it is squashed into the parent, so the name is silently ignored here but would nest the struct under other encoders", + m.field.Name, m.elemType.Name(), o.tagName()) +} + +// hasExplicitName reports whether field's tag names it, as opposed to carrying only options +// (`toml:",inline"`) or no tag at all. +func (o Options) hasExplicitName(field reflect.StructField) bool { + tag := field.Tag.Get(o.tagName()) + if tag == "" || tag == "-" { + return false + } + return strings.Split(tag, ",")[0] != "" +} + +// format is Format, or configdoc.TOML if unset. +func (o Options) format() configdoc.Format { + if o.Format == nil { + return configdoc.TOML{} + } + return o.Format +} + +// decoderConfigFor returns a copy of o.DecoderConfig aimed at target. +func (o Options) decoderConfigFor(target any) *mapstructure.DecoderConfig { + dc := o.DecoderConfig + dc.Result = target + if dc.MatchName == nil { + dc.MatchName = matchKeyToFieldName + } + return &dc +} + +// matchKeyToFieldName matches a config key against a field name (or tag) ignoring case and word +// separators, so an untagged field is reached by the key it was bound under: tagKey names it +// "finality-tag-enabled", and mapstructure's own case-insensitive comparison would not see that +// as FinalityTagEnabled. Only consulted after mapstructure's exact lookup fails, so an explicit +// tag still matches itself. +func matchKeyToFieldName(mapKey, fieldName string) bool { + return strings.EqualFold(stripSeparators(mapKey), stripSeparators(fieldName)) +} + +func stripSeparators(s string) string { + return strings.NewReplacer("-", "", "_", "").Replace(s) +} diff --git a/pkg/config/flags/tmp/docs/CONFIG.md b/pkg/config/flags/tmp/docs/CONFIG.md new file mode 100644 index 0000000000..a86a53475f --- /dev/null +++ b/pkg/config/flags/tmp/docs/CONFIG.md @@ -0,0 +1,122 @@ +# myapp Configuration + +## Example + +```toml +# ----- Global Configuration ----- + +[chain] +id = 1 +rpc = 'https://mainnet.infura.io/v3/API_KEY' +contract = '0xYourContractAddress' + +[system] +env = 'dev' +log_level = '' +metrics = false + +# ----- Command: myapp bar ----- +[bar] +retries = 3 +backoff = '2s' + +# ----- Command: myapp foo ----- +[foo] +timeout = '5s' + + +``` + +# Global Configuration + +## chain +```toml +[chain] +id = 1 # Default +rpc = 'https://mainnet.infura.io/v3/API_KEY' # Example +contract = '0xYourContractAddress' # Example +``` + + +### id +```toml +id = 1 # Default +``` +id Target chain ID selector + +### rpc +```toml +rpc = 'https://mainnet.infura.io/v3/API_KEY' # Example +``` +rpc RPC endpoint URL + +### contract +```toml +contract = '0xYourContractAddress' # Example +``` +contract Core contract address + +## system +```toml +[system] +env = 'dev' # Default +log_level = '' # Default +metrics = false # Default +``` + + +### env +```toml +env = 'dev' # Default +``` +env Environment profile selector (dev, prod) + +### log_level +```toml +log_level = '' # Default +``` +log_level Logging severity + +### metrics +```toml +metrics = false # Default +``` +metrics Enable metrics collection + +# Command: myapp bar + +## bar +```toml +[bar] +retries = 3 # Default +backoff = '2s' # Default +``` +Bar-specific settings + +### retries +```toml +retries = 3 # Default +``` +retries Bar max retries + +### backoff +```toml +backoff = '2s' # Default +``` +backoff Bar retry backoff + +# Command: myapp foo + +## foo +```toml +[foo] +timeout = '5s' # Default +``` +Foo-specific settings + +### timeout +```toml +timeout = '5s' # Default +``` +timeout Foo operation timeout + diff --git a/pkg/config/flags/tmp/main.go b/pkg/config/flags/tmp/main.go new file mode 100644 index 0000000000..d04333adf7 --- /dev/null +++ b/pkg/config/flags/tmp/main.go @@ -0,0 +1,158 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "time" + + "github.com/smartcontractkit/chainlink-common/pkg/config" + "github.com/smartcontractkit/chainlink-common/pkg/config/flags" + "github.com/spf13/cobra" +) + +// --- CONFIG STRUCTS --- + +type ChainConfig struct { + ID uint32 `toml:"id" json:"id" usage:"Target chain ID selector"` + RPC string `toml:"rpc" json:"rpc" usage:"RPC endpoint URL" validate:"required" example:"'https://mainnet.infura.io/v3/API_KEY'"` + Contract string `toml:"contract" json:"contract" usage:"Core contract address" validate:"required" example:"'0xYourContractAddress'"` +} + +type SystemConfig struct { + Env string `toml:"env" json:"env" usage:"Environment profile selector (dev, prod)"` + LogLevel string `toml:"log_level" json:"log_level" usage:"Logging severity"` + Metrics bool `toml:"metrics" json:"metrics" usage:"Enable metrics collection"` +} + +type CommonConfig struct { + Chain ChainConfig `toml:"chain" json:"chain"` + System SystemConfig `toml:"system" json:"system"` +} + +type FooSettings struct { + Timeout *config.Duration `toml:"timeout" json:"timeout" usage:"Foo operation timeout"` +} + +type BarSettings struct { + Retries int `toml:"retries" json:"retries" usage:"Bar max retries"` + // non-pointer config.Duration, to exercise the value form (UnmarshalText has a + // pointer receiver, so this decodes differently from FooSettings.Timeout) + Backoff config.Duration `toml:"backoff" json:"backoff" usage:"Bar retry backoff"` +} + +// --- PROFILE DICTIONARIES --- + +var ChainProfiles = map[uint32]CommonConfig{ + 1: { + Chain: ChainConfig{ + RPC: "https://eth.blastapi.io", + Contract: "0x1111111111111111111111111111111111111111", + }, + }, + 137: { + Chain: ChainConfig{ + RPC: "https://polygon.blastapi.io", + Contract: "0x2222222222222222222222222222222222222222", + }, + }, +} + +var SystemProfiles = map[string]CommonConfig{ + "dev": { + System: SystemConfig{ + LogLevel: "debug", + Metrics: false, + }, + }, + "prod": { + System: SystemConfig{ + LogLevel: "info", + Metrics: true, + }, + }, +} + +// --- STATE DECLARATIONS --- + +var ( + configFile string + + AppConfig = CommonConfig{ + Chain: ChainConfig{ID: 1}, + System: SystemConfig{Env: "dev"}, + } + + fooSettings = FooSettings{Timeout: config.MustNewDuration(5 * time.Second)} + barSettings = BarSettings{Retries: 3, Backoff: *config.MustNewDuration(2 * time.Second)} +) + +// --- COMMAND DECLARATIONS --- + +var rootCmd = &cobra.Command{ + Use: "myapp", +} + +var fooCmd = &cobra.Command{ + Use: "foo", + Short: "Foo-specific settings", + RunE: func(cmd *cobra.Command, args []string) error { + fmt.Println("FOO") + return printConfig(AppConfig, fooSettings) + }, +} + +var barCmd = &cobra.Command{ + Use: "bar", + Short: "Bar-specific settings", + RunE: func(cmd *cobra.Command, args []string) error { + fmt.Println("BAR") + return printConfig(AppConfig, barSettings) + }, +} + +func printConfig(common CommonConfig, settings any) error { + out, err := json.MarshalIndent(struct { + Common CommonConfig `json:"common"` + Cmd any `json:"cmd"` + }{common, settings}, "", " ") + if err != nil { + return err + } + fmt.Println(string(out)) + return nil +} + +func init() { + rootCmd.PersistentFlags().StringVar(&configFile, "config", "", "Path to config file") + + // 1. Register base persistent flags on root with env prefixes ("CRE", "CL") + if err := flags.RegisterCommandFlags(rootCmd, &AppConfig, flags.DefaultTOMLOptions("CRE", "CL")); err != nil { + panic(err) + } + + // 2. Attach profile maps to root command + if err := flags.RegisterProfile(rootCmd, "Chain.ID", ChainProfiles, flags.DefaultTOMLOptions("CRE", "CL")); err != nil { + panic(err) + } + if err := flags.RegisterProfile(rootCmd, "System.Env", SystemProfiles, flags.DefaultTOMLOptions("CRE", "CL")); err != nil { + panic(err) + } + + // 3. Register subcommand flags (inherits "CRE" / "CL" prefixes automatically) + if err := flags.RegisterSubcommandFlags(fooCmd, "foo", &fooSettings, flags.DefaultTOMLOptions()); err != nil { + panic(err) + } + if err := flags.RegisterSubcommandFlags(barCmd, "bar", &barSettings, flags.DefaultTOMLOptions()); err != nil { + panic(err) + } + + rootCmd.AddCommand(fooCmd) + rootCmd.AddCommand(barCmd) +} + +func main() { + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} diff --git a/pkg/config/flags/tmp/tmp b/pkg/config/flags/tmp/tmp new file mode 100755 index 0000000000..5976e9c090 Binary files /dev/null and b/pkg/config/flags/tmp/tmp differ diff --git a/pkg/config/flags/walk.go b/pkg/config/flags/walk.go new file mode 100644 index 0000000000..59341affda --- /dev/null +++ b/pkg/config/flags/walk.go @@ -0,0 +1,175 @@ +package flags + +import ( + "encoding" + "fmt" + "reflect" + "strings" + + "github.com/iancoleman/strcase" +) + +// fieldMeta describes one field discovered while walking a config struct. Shared by the +// CLI/env/viper binding logic (bindLeafFlag) and the doc-generation logic (docs.go). +type fieldMeta struct { + // goPath is the chain of Go field names from the root struct to this field. + goPath []string + // keyPath is the chain of toml/mapstructure keys from the root struct to this field. + // Squashed and anonymous fields contribute no key segment of their own. + keyPath []string + // field is the raw struct field (field.Type may be a pointer). + field reflect.StructField + // parent is the struct type declaring field, used to resolve sibling field names (e.g. + // the targets of cross-field `validate` rules) back to their config keys. + parent reflect.Type + // elem is field's value with one level of pointer dereferenced (or the zero value of + // elemType, if the pointer is nil). + elem reflect.Value + // elemType is field.Type with one level of pointer stripped. + elemType reflect.Type + // isTextUnmarshaler is true if elemType (or a pointer to it) implements + // encoding.TextUnmarshaler, meaning it should be treated as an opaque leaf value even + // though its Kind may be Struct. + isTextUnmarshaler bool +} + +// key joins keyPath into a dotted key, e.g. "chain.id". +func (f fieldMeta) key() string { return strings.Join(f.keyPath, ".") } + +// goName joins goPath into a dotted Go field path, e.g. "Chain.ID". +func (f fieldMeta) goName() string { return strings.Join(f.goPath, ".") } + +var textUnmarshalerType = reflect.TypeOf((*encoding.TextUnmarshaler)(nil)).Elem() + +// tagKey returns field's config key and whether it is squashed (contributing its own fields to +// the parent rather than a nested table), per the configured tag name and squash option - e.g. +// `toml:"key,inline"` with Options.DecoderConfig.TagName "toml" and SquashTagOption "inline". +// Falls back to the Go field name in kebab-case when the field carries no usable tag, so a +// struct only needs a tag where its key differs from its field name. +func (o Options) tagKey(field reflect.StructField) (key string, squash bool) { + tag := field.Tag.Get(o.tagName()) + if tag == "" || tag == "-" { + tag = strcase.ToKebab(field.Name) + } + + parts := strings.Split(tag, ",") + key = parts[0] + if key == "" { + key = strcase.ToKebab(field.Name) + } + for _, p := range parts[1:] { + if p == o.squashOption() { + squash = true + } + } + + // An embedded struct is squashed without any tag, but only when the decoder is configured + // to do so - mirroring mapstructure's own `d.config.Squash && v.Kind() == reflect.Struct && + // f.Anonymous`. Squashing here when the decoder won't (or vice versa) would name the flag + // and the decoded key differently, and the value would silently never arrive. + if o.DecoderConfig.Squash && field.Anonymous && field.Type.Kind() == reflect.Struct { + squash = true + } + + return key, squash +} + +// implementsTextUnmarshaler reports whether t (or *t) implements encoding.TextUnmarshaler. +func implementsTextUnmarshaler(t reflect.Type) bool { + return t.Implements(textUnmarshalerType) || reflect.PointerTo(t).Implements(textUnmarshalerType) +} + +// structVisitor holds the callbacks invoked while walking a struct. +type structVisitor struct { + // leaf is called for every leaf field (scalar, or struct implementing + // encoding.TextUnmarshaler). + leaf func(fieldMeta) error + // branch is optionally called for every non-leaf (nested struct) field before it is + // recursed into. Returning skip=true prevents descending into it (no further leaf/branch + // calls for anything under it). + branch func(fieldMeta) (skip bool, err error) +} + +// walkStruct recursively visits every field of target (a struct or pointer to a struct) in +// declaration order, calling visitor.leaf for scalar fields and visitor.branch (if set) for +// nested struct fields. Keys are derived using opts, so the walk agrees with how opts' +// decoder will later map the same fields. +func walkStruct(target any, opts Options, visitor structVisitor) error { + v := reflect.ValueOf(target) + if v.Kind() == reflect.Pointer { + if v.IsNil() { + return fmt.Errorf("target pointer cannot be nil") + } + v = v.Elem() + } + if v.Kind() != reflect.Struct { + return fmt.Errorf("target must be a struct or pointer to struct") + } + return walkStructValue(v, v.Type(), nil, nil, opts, visitor) +} + +func walkStructValue(v reflect.Value, t reflect.Type, goPath, keyPath []string, opts Options, visitor structVisitor) error { + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + if field.PkgPath != "" { + continue // unexported + } + + key, squash := opts.tagKey(field) + + fieldGoPath := append(append([]string{}, goPath...), field.Name) + fieldKeyPath := keyPath + if !squash { + fieldKeyPath = append(append([]string{}, keyPath...), key) + } + + fieldVal := v.Field(i) + elemType := field.Type + elemVal := fieldVal + if elemType.Kind() == reflect.Pointer { + elemType = elemType.Elem() + if fieldVal.IsNil() { + elemVal = reflect.Zero(elemType) + } else { + elemVal = fieldVal.Elem() + } + } + + isTextUnmarshaler := implementsTextUnmarshaler(field.Type) || implementsTextUnmarshaler(elemType) + + meta := fieldMeta{ + goPath: fieldGoPath, + keyPath: fieldKeyPath, + field: field, + parent: t, + elem: elemVal, + elemType: elemType, + isTextUnmarshaler: isTextUnmarshaler, + } + + if elemType.Kind() == reflect.Struct && !isTextUnmarshaler { + skip := false + if visitor.branch != nil { + var err error + skip, err = visitor.branch(meta) + if err != nil { + return err + } + } + if skip { + continue + } + if err := walkStructValue(elemVal, elemType, fieldGoPath, fieldKeyPath, opts, visitor); err != nil { + return err + } + continue + } + + if visitor.leaf != nil { + if err := visitor.leaf(meta); err != nil { + return err + } + } + } + return nil +} diff --git a/pkg/ocrcommon/config.go b/pkg/ocrcommon/config.go new file mode 100644 index 0000000000..0c898e18db --- /dev/null +++ b/pkg/ocrcommon/config.go @@ -0,0 +1,26 @@ +package ocrcommon + +import ( + "github.com/smartcontractkit/chainlink-common/pkg/config" +) + +// Config is the networking configuration a process needs to run a local libocr rage p2p V2 +// peer. Tagged for chainlink-common's pkg/config/flags, so a binary can bind it as flags, env +// vars and config-file keys directly. +// +// It says nothing about delegating networking elsewhere instead of creating a peer: that is a +// property of the process, not of the peer, so a binary offering that mode embeds this struct in +// one of its own that adds it. Nor does it say where the peer's identity comes from: a process +// may unlock it from a keystore, derive it deterministically, or be handed one, so the settings +// that choice needs (a keystore password, say) belong to that process and not here. Defaults are +// the binary's too - the zero value of this struct is just a zero value, and the binary binding +// it supplies the instance to decode into. +type Config struct { + ListenAddresses []string `usage:"rage p2p V2 listen addresses (host:port); creates a local peer" example:"['127.0.0.1:1234']"` + AnnounceAddresses []string `usage:"rage p2p V2 announce addresses (host:port); defaults to the listen addresses" validate:"excluded_without=ListenAddresses"` + DeltaReconcile config.Duration `usage:"rage p2p V2 delta reconcile interval"` + DeltaDial config.Duration `usage:"rage p2p V2 minimum interval between dial attempts"` + + IncomingBufferSize int `usage:"per-remote incoming message buffer size"` + OutgoingBufferSize int `usage:"per-remote outgoing message buffer size"` +} diff --git a/pkg/sqlutil/config.go b/pkg/sqlutil/config.go new file mode 100644 index 0000000000..f7948c9f3d --- /dev/null +++ b/pkg/sqlutil/config.go @@ -0,0 +1,28 @@ +package sqlutil + +import ( + "database/sql" + "errors" + + // Register the pgx database/sql driver under the name "pgx". + _ "github.com/jackc/pgx/v5/stdlib" +) + +// Config is the configuration for connecting to the database. Tagged for +// chainlink-common's pkg/config/flags, so a binary can bind it as flags, env vars and +// config-file keys directly. +type Config struct { + URL string `usage:"database url" example:"'postgresql://user:password@localhost:5432/chainlink?sslmode=disable'"` +} + +// OpenDB opens the database at c.URL with the pgx driver. +// +// As with [sql.Open], no connection is established until the returned DB is used, so a bad +// host or credentials surface on first query rather than here. The caller owns the DB and must +// Close it. +func OpenDB(c Config) (*sql.DB, error) { + if c.URL == "" { + return nil, errors.New("database url is required") + } + return sql.Open("pgx", c.URL) +}