-
Notifications
You must be signed in to change notification settings - Fork 886
[ConfigManager] Register Node Sections 1/4 #3992
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
1da0d72
467ceec
25fa5f9
59a387a
f7dc6bb
8466082
0e1d870
12fe6c0
d87787d
dc4a458
db12097
eb53ea6
02878d0
9b2d183
3f9d618
e244418
7fc9154
517f240
dd25b13
13d1aab
9040975
534a847
905df20
1eab9bc
a8fe04f
261a308
d9b69bd
5d0f3c3
8da6edd
1c57dd9
ca21fc9
561d5db
a4c11ef
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -51,6 +51,13 @@ type Section struct { | |
| Prefix string | ||
| // Keys are the dotted paths this section declares, sorted. | ||
| Keys []string | ||
| // Excluded are dotted paths the struct carries that this section deliberately does not declare, | ||
| // sorted. | ||
| // | ||
| // Kept rather than discarded because both walks have to agree. The type walk decides what is declared | ||
| // and the value walk decides what is stated, and a path dropped from one and not the other makes a | ||
| // section that either declares a key nothing answers or answers a key it never declared. | ||
| Excluded []string | ||
| // Defaults returns the section's default for a mode. | ||
| Defaults func(Mode) any | ||
| } | ||
|
|
@@ -87,7 +94,31 @@ var ( | |
| // It never panics. A registration this package cannot use is recorded as a Defect and the | ||
| // section is not registered. | ||
| func RegisterSection(name string, prototype any, defaults func(Mode) any) { | ||
| record(name, name, prototype, defaults) | ||
| record(name, name, prototype, defaults, nil) | ||
| } | ||
|
|
||
| // RegisterSectionExcluding records a section, leaving out paths the struct carries that are not settings. | ||
| // | ||
| // Each excluding path is relative to the section, so "max-outbound-connections" rather than the dotted key | ||
| // it becomes. A path matching nothing the struct declares is refused, because an exclusion covering nothing | ||
| // reads as though it covered something. | ||
| // | ||
| // Two kinds of field earn this. One a reader refuses outright, where writing the key stops the node, so | ||
| // declaring it would put a setting in the space whose only effect is an outage. And one whose absence is | ||
| // itself the setting, where any default would be this package inventing one. | ||
| // | ||
| // A field can also carry "-" as its mapstructure name, and that says something different rather than the | ||
| // same thing in another place. The tag says the field is not configuration and no reader offers it. An | ||
| // exclusion says the opposite about the field and less about this package: the field is configuration, the | ||
| // reader that owns its file decodes it, and this key space does not offer it. Tagging one of these paths | ||
| // would take the setting away from that reader. | ||
| // | ||
| // The tag is a statement of intent and not a barrier. The decoder in use honours "-" when it renders a | ||
| // struct into a map and not when it decodes a map into a struct, where it takes the name literally, so a | ||
| // key spelled "-" reaches every field in the struct tagged that way at once. Nothing offers such a key and | ||
| // no operator writes one, which is why this is stated rather than guarded. | ||
| func RegisterSectionExcluding(name string, prototype any, defaults func(Mode) any, excluding ...string) { | ||
| record(name, name, prototype, defaults, excluding) | ||
| } | ||
|
|
||
| // RegisterRootKeys records a section whose keys sit at the root of the file, with no section of their own. | ||
|
|
@@ -98,12 +129,30 @@ func RegisterSection(name string, prototype any, defaults func(Mode) any) { | |
| // Some settings are node-wide and are written at the top of a file rather than inside a table. Giving them | ||
| // a section would rename them, and a renamed key is one an operator's existing file no longer reaches. | ||
| func RegisterRootKeys(name string, prototype any, defaults func(Mode) any) { | ||
| record(name, "", prototype, defaults) | ||
| record(name, "", prototype, defaults, nil) | ||
| } | ||
|
|
||
| // RegisterRootKeysExcluding records a root-key section, leaving out paths that are not settings. | ||
| // | ||
| // RegisterSectionExcluding says which fields earn an exclusion and how a path is spelled. | ||
| func RegisterRootKeysExcluding(name string, prototype any, defaults func(Mode) any, excluding ...string) { | ||
| record(name, "", prototype, defaults, excluding) | ||
| } | ||
|
|
||
| // record is the one path both registrations take. | ||
| func record(name, prefix string, prototype any, defaults func(Mode) any) { | ||
| func record(name, prefix string, prototype any, defaults func(Mode) any, excluding []string) { | ||
| keys, err := deriveKeys(name, prefix, prototype) | ||
| var excluded []string | ||
| if err == nil { | ||
| keys, excluded, err = withoutExcluded(prefix, keys, excluding) | ||
| } | ||
| // Refused after the exclusions are known, because deriveKeys can only see a struct that declares | ||
| // nothing and this catches the section whose every declared path was then excluded. Such a section | ||
| // answers no key while still taking its name, so the real registration under that name is later | ||
| // refused as a duplicate, and its defaults are still rendered on every resolution. | ||
| if err == nil && len(keys) == 0 { | ||
| err = fmt.Errorf("every path it declares is excluded (%v), so the section declares nothing", excluded) | ||
| } | ||
|
|
||
| mu.Lock() | ||
| defer mu.Unlock() | ||
|
|
@@ -121,10 +170,53 @@ func record(name, prefix string, prototype any, defaults func(Mode) any) { | |
| defects = append(defects, Defect{Section: name, Err: err}) | ||
| return | ||
| } | ||
| sections[name] = Section{Name: name, Prefix: prefix, Keys: keys, Defaults: defaults} | ||
| sections[name] = Section{ | ||
| Name: name, Prefix: prefix, Keys: keys, Excluded: excluded, Defaults: defaults, | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // withoutExcluded splits derived paths into the ones a section declares and the ones it does not. | ||
| // | ||
| // An exclusion is spelled relative to the section, so this is where it becomes the dotted key both walks | ||
| // compare against. One matching no derived path is refused: the field it named was renamed or removed, and | ||
| // an exclusion for a field that is gone stops excluding anything while still reading as a deliberate | ||
| // omission. | ||
| func withoutExcluded(prefix string, derived, excluding []string) (keys, excluded []string, err error) { | ||
| if len(excluding) == 0 { | ||
| return derived, nil, nil | ||
| } | ||
| drop := make(map[string]bool, len(excluding)) | ||
| for _, rel := range excluding { | ||
| key := rel | ||
| if prefix != "" { | ||
| key = prefix + "." + rel | ||
| } | ||
| if drop[key] { | ||
| return nil, nil, fmt.Errorf("%s is excluded twice, and the second one covers nothing", key) | ||
| } | ||
| drop[key] = true | ||
| } | ||
| for _, key := range derived { | ||
| if drop[key] { | ||
| excluded = append(excluded, key) | ||
| delete(drop, key) | ||
| continue | ||
| } | ||
| keys = append(keys, key) | ||
| } | ||
| if len(drop) > 0 { | ||
| missing := make([]string, 0, len(drop)) | ||
| for key := range drop { | ||
| missing = append(missing, key) | ||
| } | ||
| sort.Strings(missing) | ||
| return nil, nil, fmt.Errorf("%v is excluded and the struct declares no such key, so the "+ | ||
| "exclusion covers nothing", missing) | ||
| } | ||
| return keys, excluded, nil | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [suggestion] A section whose every derived path is excluded registers with
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed, refused rather than re-checked. Such a section answers no key while taking its name, so the real registration under that name is then refused as a duplicate, and its defaults are still rendered on every resolution. The refusal now sits in |
||
| } | ||
|
|
||
|
cursor[bot] marked this conversation as resolved.
|
||
| // envNamesAreDistinct refuses keys that share one environment spelling. Callers hold mu. | ||
| // | ||
| // Dots and hyphens both become underscores, so two keys differing only in that punctuation answer to | ||
|
|
@@ -160,14 +252,24 @@ func envNamesAreDistinct(adding []string) error { | |
| return nil | ||
| } | ||
|
|
||
| // detached returns a copy of the section that shares no storage with the registry's own. | ||
| // | ||
| // Every path out of the mutex goes through this, so a slice field added to Section later is copied | ||
| // without anyone having to remember to copy it at each accessor. A caller that sorts or writes into | ||
| // what it was handed reaches its own storage, and both walks keep reading what registration decided. | ||
| func (s Section) detached() Section { | ||
| s.Keys = append([]string(nil), s.Keys...) | ||
| s.Excluded = append([]string(nil), s.Excluded...) | ||
| return s | ||
| } | ||
|
|
||
| // Sections returns every registered section, sorted by name. | ||
| func Sections() []Section { | ||
| mu.RLock() | ||
| defer mu.RUnlock() | ||
| out := make([]Section, 0, len(sections)) | ||
| for _, s := range sections { | ||
| s.Keys = append([]string(nil), s.Keys...) | ||
| out = append(out, s) | ||
| out = append(out, s.detached()) | ||
| } | ||
| sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) | ||
| return out | ||
|
|
@@ -178,10 +280,7 @@ func Lookup(name string) (Section, bool) { | |
| mu.RLock() | ||
| defer mu.RUnlock() | ||
| s, ok := sections[name] | ||
| // Copied, so a caller sorting or writing into Keys cannot reach the registry's own storage from | ||
| // outside the mutex. Defects copies for the same reason. | ||
| s.Keys = append([]string(nil), s.Keys...) | ||
| return s, ok | ||
| return s.detached(), ok | ||
| } | ||
|
|
||
| // Defects returns every registration this package could not use. | ||
|
|
@@ -367,7 +466,10 @@ func tagOf(f reflect.StructField, label string) (name string, squash, skip bool, | |
| "unreachable through their tags", label, f.Name) | ||
| } | ||
|
|
||
| name, squash = parseTag(tag) | ||
| name, squash, remain := parseTag(tag) | ||
| if remain { | ||
| return "", false, true, nil | ||
| } | ||
| if squash { | ||
| if name != "" { | ||
| return "", false, false, fmt.Errorf("%s.%s is squashed and also names %q; one or the other", | ||
|
|
@@ -394,15 +496,23 @@ func tagOf(f reflect.StructField, label string) (name string, squash, skip bool, | |
| return name, false, false, nil | ||
| } | ||
|
|
||
| // parseTag splits a mapstructure tag into the name it gives a field and whether it squashes. | ||
| func parseTag(tag string) (name string, squash bool) { | ||
| // parseTag splits a mapstructure tag into the name it gives a field and the options that change what the | ||
| // field is. | ||
| // | ||
| // A squashed field contributes its own fields at this level rather than a segment of its own. A remaining | ||
| // field is where the decode puts what it matched no field for, so it declares no key: what lands in it is | ||
| // what an operator misspelled, and giving it a key would offer the collector itself as a setting. | ||
| func parseTag(tag string) (name string, squash, remain bool) { | ||
| parts := strings.Split(tag, ",") | ||
| for _, opt := range parts[1:] { | ||
| if opt == "squash" { | ||
| switch opt { | ||
| case "squash": | ||
| squash = true | ||
| case "remain": | ||
| remain = true | ||
| } | ||
| } | ||
| return parts[0], squash | ||
| return parts[0], squash, remain | ||
| } | ||
|
|
||
| // assignedOutsideConfiguration reports whether a tag excludes its field from configuration. | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[suggestion]
Excludedescapes the mutex uncopied.Sections()(registry.go:246) andLookup()(registry.go:260) both copyKeys— with a comment saying it is "so a caller sorting or writing into Keys cannot reach the registry's own storage from outside the mutex" — but neither copies the newExcludedslice, so a caller that sorts or appends to it mutates registry state, racing every concurrentResolvethat readss.ExcludedindefaultValues. Copy it alongsideKeysin both, and extendTestAReadKeySliceCannotReachTheRegistryto cover it.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed, and the consequence is sharper than a wrong exclusion.
Excludedis read onresolve.go:176on everyResolve, so a caller writing into it drops a path from one walk and not the other, andmatchesDeclarationthen fails for every mode for the life of the process.Both accessors now go through one
detached(), so a slice field added toSectionlater is copied without anyone remembering to do it twice. The regression test writes by index rather than appending, which matters: every section registered today has capacity equal to length, so an append reallocates and a test built on one would have passed while the defect was present.