Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
0d245d2
config/seitoml: read, edit and write a node's sei.toml
bdchatham Aug 18, 2026
8b24818
config/seitoml: decode with TOML's grammar, and refuse the shapes it …
bdchatham Aug 18, 2026
f70ad19
config/seitoml: call a default a default
bdchatham Aug 18, 2026
7e4746c
config/seitoml: drop generated_by, which nothing reads
bdchatham Aug 18, 2026
0bfb84b
config/seitoml: read values with a conforming decoder instead of by hand
bdchatham Aug 18, 2026
832751a
config/seitoml: say why the atomic write is by hand
bdchatham Aug 18, 2026
cc0f2c0
Merge branch 'main' into plt-775-seitoml
bdchatham Aug 18, 2026
67ee9b5
config/seitoml: apply one key rule everywhere, and refuse a shadowed …
bdchatham Aug 18, 2026
54db0a6
config/seitoml: admit only a bare key, and close three round-trip gaps
bdchatham Aug 18, 2026
a438899
config/seitoml: decode with the library a node reads its configuratio…
bdchatham Aug 18, 2026
7518243
config/seitoml: let the global section own a table its dotted keys cr…
bdchatham Aug 18, 2026
3078f55
config/seitoml: delimit the generated preamble, and cache the decode …
bdchatham Aug 19, 2026
5fa839d
config/seitoml: stop a read from changing what the next read answers
bdchatham Aug 19, 2026
1dcef6f
config/seitoml: remove SetPreamble, and make the cache and ordering t…
bdchatham Aug 19, 2026
9b80106
config/seitoml: stop handing out the cache's lists
bdchatham Aug 19, 2026
e88c4bb
config/seitoml: keep the comment beside a value across an edit
bdchatham Aug 19, 2026
169f961
config/seitoml: Save's error means one thing
bdchatham Aug 19, 2026
37bde47
config/seitoml: refuse an unreadable schema version at the door
bdchatham Aug 19, 2026
1004899
config/seitoml: one spelling per table, so nothing has to choose betw…
bdchatham Aug 19, 2026
a5565a3
config/seitoml: say why two shapes are checked twice
bdchatham Aug 19, 2026
0a36aea
Merge remote-tracking branch 'origin/main' into plt-775-seitoml
bdchatham Aug 19, 2026
a85dae6
config/seitoml: Save asks whether the file it writes can be loaded
bdchatham Aug 19, 2026
0fc32b5
config/seitoml: Parse reads the node mode too, not only the counter
bdchatham Aug 19, 2026
b64a935
config/seitoml: hold the documentation to what the code does
bdchatham Aug 19, 2026
a329841
Merge branch 'main' into plt-775-seitoml
bdchatham Aug 19, 2026
ea05e6d
config/seitoml: state each boundary where it applies, not in a sectio…
bdchatham Aug 19, 2026
b1f852b
Merge remote-tracking branch 'origin/plt-775-seitoml' into plt-775-se…
bdchatham Aug 19, 2026
c27dd38
config/seitoml: name the same key on every read, and clear four residues
bdchatham Aug 19, 2026
e24d9a3
Merge branch 'main' into plt-775-seitoml
bdchatham Aug 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions config/seitoml/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Package seitoml reads, edits and writes the node's sei.toml.
//
// A File is a mutable in-memory document for one goroutine at a time.
//
// Apart from the two keys below, the file holds only what an operator decided. A key present in it is
// authoritative; a key absent from it resolves to the running binary's default for the node's mode.
// Nothing here writes a default into the file, because a value the binary put there reads exactly like
// one an operator chose.
//
// Two keys at the top level describe the file rather than configure the node, and Values leaves both
// out. Values reports the keys the file writes and nothing about them: which section owns a key, and
// whether any section does, is answered elsewhere.
//
// schema_version which migration the file has reached
// node_mode which mode's defaults its values were chosen against
//
// schema_version counts migrations, one per migration, and is deliberately not a release version. Parse
// reads both keys before it returns a file, so every verb below answers for one whose schema and mode are
// established. It refuses a counter that is absent, not a whole number, below the first schema, or ahead
// of the one this binary understands, and a mode that is absent, not text, or empty. This package reads
// and writes the counter; what acts on it arrives with the migrations.
//
// # Editing Preserves the Document
//
// An operator hand-edits this file, and the comments in it are how they explain a choice to whoever reads
// it next. So Set and Unset write the key they name and, when its table is new, that table's heading.
// They add nothing else, and every other line of content stays as it was. A comment above a key and a
// comment beside a value both survive an edit, and a key's own comment leaves with it when the key is
// unset.
//
// A table is named one way, by a heading, so there is no second spelling for an insert to choose between.
//
// Vertical spacing normalises once, on the first save of a file nothing has saved before, and holds
// from then on.
//
// Every write is atomic. A save lands in full or not at all, and a failed save leaves no temporary file
// behind.
//
// A value read from the file is written back as the same type.
//
// # One Decoder Decides What Parses
//
// The decoder is the one a node reads its configuration with. viper decodes TOML with
// pelletier/go-toml/v2, so this package decodes with it too, and a file this package accepts is a file
// the node's own decoder accepts. Whether its values make a bootable node is answered elsewhere.
//
// So the question a shape has to answer is put to that decoder rather than to a list kept here. Parse asks
// it. So does a Set that adds a key: it inserts, renders, asks again, and undoes the write and names the
// key when the answer is no. A Set that replaces a value on an existing line changes no shape and does
// not ask, nor does Unset, and Save asks once more over the whole document before anything reaches disk.
// So a shape nobody anticipated is refused as surely as one somebody did, and nothing reaches a node's
// disk unread.
//
// Two shapes are checked here as well, and deliberately: a repeated key and a repeated heading. The
// decoder refuses both, so nothing about whether the file loads rests on these; what they add is the
// diagnosis. They name the dotted key an operator typed and say that an edit reaches only the first,
// where the decoder names a line and reports that the name already exists. Both are the mistake
// hand-editing produces most.
//
// The refusals in the next section are the other direction: shapes the decoder accepts and this file
// does not carry.
//
// The editing parser is a second library doing a different job: it locates lines and preserves comments,
// and stops short of interpreting a literal.
//
// # What This File May Hold
//
// These are refused although the decoder accepts them, because this package has to write back what it
// reads:
//
// - an infinity or a NaN, which have no form to write
// - a date or a time, which nothing configures a node with and which cannot be written back as the
// type it was read as
// - an inline table, whose keys flatten into the same space a table's do, so an edit to one of them
// has no line of its own to change
// - a dotted key, whose segments before the last name tables with no line of their own, so a key
// added to one of those tables has nowhere to go
// - an array of tables, which flattens to one key holding a list of tables, so no entry has a line of
// its own to edit
//
// Every segment of a key is a lower-case bare TOML key: letters, digits, underscores and hyphens. Anything else has to be
// quoted where it is written, and a quoted key is spelled one way by the decoder and another by a
// lookup, so Values would report a key Get answers absent for. Set, Unset and Get fold a caller's key to
// lower case and then hold it to that rule, so a key one of them writes is a key the file reads back.
package seitoml
Comment thread
bdchatham marked this conversation as resolved.
278 changes: 278 additions & 0 deletions config/seitoml/edit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
package seitoml

import (
"fmt"
"math"
"strconv"
"strings"
"time"
"unicode/utf8"

"github.com/creachadair/tomledit"
"github.com/creachadair/tomledit/parser"
"github.com/creachadair/tomledit/scanner"
"github.com/creachadair/tomledit/transform"
)

// Set writes one key's value, replacing it in place when the key is already present.
//
// Replacing the value on the existing line preserves the comment an operator wrote above or beside
Comment thread
bdchatham marked this conversation as resolved.
// the key. Rewriting the file from a decoded map drops every comment in it, leaving the operator no
// way to recover the reasoning they recorded.
func (f *File) Set(key string, v any) error {
path, err := keyOf(key)
if err != nil {
return err
}
value, err := tomlValue(v)
if err != nil {
return fmt.Errorf("%s: %w", key, err)
}

if e := f.doc.First(path...); e != nil && e.KeyValue != nil {
f.changed()
// The comment beside a value hangs off the value, so replacing the value drops it unless it is
// carried across. The block above the key hangs off the key instead and survives on its own.
value.Trailer = e.Value.Trailer
e.Value = value
return nil
}
f.changed()

// A key the document does not hold yet lands in a namespace that may already use its name for a
// table, or use a table's name for it. Rather than enumerate the shapes that collide, insert and then
// ask the decoder, which is the same one the node reads with: if the document no longer decodes, the
// insert is undone and the key is named. Enumerating them by hand missed three.
undo, inserted := f.insert(path, value)
if !inserted {
// Unreachable: the lookup above established the key is absent, and the dotted name insert builds
// addresses the same place. Reported rather than returned as success, because a caller told a
// write landed when nothing was written has no way to find out.
return fmt.Errorf("%s: the document already holds this key", key)
}
if err := f.decodable(); err != nil {
// Nothing to drop: the decode that just failed left no cache behind, which is why the undo needs
// no invalidation of its own.
undo()
return fmt.Errorf("%s: %w", key, err)
}
return nil
}

// decodable reports whether the document still renders to something the node's decoder can read.
//
// The check an insert passes through, because only an insert can name a place the document already uses.
// Replacing a value on an existing line changes no shape, and neither does removing a name, so neither
// asks; Save asks over the whole document instead, which is the gate every file reaching disk crosses.
// Rendering is what a later process reads, so this asks the question in the form the answer matters in.
func (f *File) decodable() error {
_, err := f.decoded()
return err
}

// insert adds a key the document does not have yet.
//
// A key with no dots belongs at the top level. Otherwise it goes in the table its prefix names,
// which is created when it is absent so writing the first key of a section works without the
// operator having to add the heading by hand.
func (f *File) insert(path parser.Key, value parser.Value) (func(), bool) {
leaf := parser.Key{path[len(path)-1]}
kv := &parser.KeyValue{Name: leaf, Value: value}

if len(path) == 1 {
return f.insertGlobal(kv)
}

table := path[:len(path)-1]
if e := transform.FindTable(f.doc, table...); e != nil {
return appendItem(e.Section, kv)
}
// No section carries this name, so the table is new and gets a heading. There is no second spelling to
// choose between: a dotted key is the other way to name a table and the document cannot hold one.
before := len(f.doc.Sections)
f.doc.Sections = append(f.doc.Sections, &tomledit.Section{
Comment thread
bdchatham marked this conversation as resolved.
Heading: &parser.Heading{Name: copyKey(table)},
Items: []parser.Item{kv},
})
return func() { f.doc.Sections = f.doc.Sections[:before] }, true
}

// copyKey returns a key that shares no storage with its argument.
//
// The paths here are slices of one another, so appending to a shorter one would write into the longer
// one's storage.
func copyKey(k parser.Key) parser.Key { return append(parser.Key(nil), k...) }

// appendItem adds an item to a section, and reports how to remove it again and whether it went in.
//
// Told not to replace, so a key already present is reported rather than overwritten. The distinction
// matters twice: replacing would leave the undo deleting an entry that predated the edit, and a caller
// needs to know a write did not happen rather than being told it did.
func appendItem(s *tomledit.Section, kv *parser.KeyValue) (func(), bool) {
if !transform.InsertMapping(s, kv, false) {
return nil, false
}
return func() {
for i, item := range s.Items {
if item == parser.Item(kv) {
s.Items = append(s.Items[:i], s.Items[i+1:]...)
return
}
}
}, true
}

// insertGlobal adds a top-level key, creating the global section when the document has none.
func (f *File) insertGlobal(kv *parser.KeyValue) (func(), bool) {
if f.doc.Global == nil {
f.doc.Global = &tomledit.Section{}
}
return appendItem(f.doc.Global, kv)
}

// Unset removes a key and reports whether the file carried one.
//
// This removes the key rather than writing a zero, because an absent key resolves to the running
// binary's default. A key set to its default value looks identical in the file but is a commitment
// that survives a release changing that default, which is the opposite of what unset means.
func (f *File) Unset(key string) (bool, error) {
path, err := keyOf(key)
if err != nil {
return false, err
}
e := f.doc.First(path...)
if e == nil || e.KeyValue == nil {
return false, nil
}
f.changed()
if !e.Remove() {
// Reported rather than returned as an absent key, which is what the file carrying one and the
// removal doing nothing would otherwise look like to a caller.
return false, fmt.Errorf("%s: the file carries this key and it could not be removed", key)
}
return true, nil
}

// tomlValue renders a Go value as the TOML literal that parses back to it.
//
// One case per type rather than a general formatter, so an unsupported type errors here instead of
// becoming a plausible-looking line in an operator's file. The cases are the widths configuration
// structs in this tree actually declare, which is why a narrower integer is a named refusal rather
// than a case: adding one is what you do when a field needs it.
//
// A duration goes in as its string form, since a bare number of nanoseconds is unreadable and reads
// back as an integer.
func tomlValue(v any) (parser.Value, error) {
switch x := v.(type) {
case bool:
return parser.ParseValue(strconv.FormatBool(x))
case string:
text, err := basicString(x)
if err != nil {
return parser.Value{}, err
}
return parser.ParseValue(text)
case time.Duration:
text, err := basicString(x.String())
if err != nil {
return parser.Value{}, err
}
return parser.ParseValue(text)
case int:
return parser.ParseValue(quoteInt(int64(x)))
case int32:
return parser.ParseValue(quoteInt(int64(x)))
case int64:
return parser.ParseValue(quoteInt(x))
case uint:
return unsignedValue(uint64(x))
case uint32:
return unsignedValue(uint64(x))
case uint64:
return unsignedValue(x)
case float64:
return floatValue(x)
case []string:
quoted, err := quoteEach(x)
if err != nil {
return parser.Value{}, err
}
return parser.ParseValue("[" + strings.Join(quoted, ", ") + "]")
case []any:
// The shape reading an array back produces. Without this, anything that reads a list and writes
// it again fails on a value this package handed it. Every element is a value the reader can
// produce, and every one of those has a case above, so the reader and the writer agree.
rendered := make([]string, 0, len(x))
for i, item := range x {
element, err := tomlValue(item)
if err != nil {
return parser.Value{}, fmt.Errorf("element %d: %w", i, err)
}
rendered = append(rendered, element.String())
}
return parser.ParseValue("[" + strings.Join(rendered, ", ") + "]")
default:
return parser.Value{}, fmt.Errorf("cannot write a %T to a configuration file", v)
}
}

// unsignedValue renders an unsigned integer, refusing one no reader can hand back.
//
// A TOML integer is signed and decodes into an int64, so a value above its maximum renders as a line
// that reads back as an error rather than a number. Refused here for the same reason an infinity is:
// this package does not write what it cannot read.
func unsignedValue(x uint64) (parser.Value, error) {
if x > math.MaxInt64 {
return parser.Value{}, fmt.Errorf("%d is larger than a configuration file's integers go, which "+
"reach %d", x, int64(math.MaxInt64))
}
return parser.ParseValue(strconv.FormatUint(x, 10))
}

// floatValue renders a float as a TOML float, which the shortest form of an integral one is not.
//
// TOML tells a float from an integer by the fractional part or the exponent, and the shortest form of
// 1.0 is "1", which reads back as an integer. A key declared as a float would then resolve as one type
// from a node's own files and as another from its sei.toml, and which of the two an operator gets
// depends on the value they chose: 0.5 survives and 1.0 does not.
//
// Infinities and NaN are refused, because this file format has no form for either. The alternative is
// a line no reader can load, written into an operator's file with nothing said.
func floatValue(x float64) (parser.Value, error) {
if math.IsInf(x, 0) || math.IsNaN(x) {
return parser.Value{}, fmt.Errorf("%v cannot be written to a configuration file, which holds "+
"finite numbers", x)
}
text := strconv.FormatFloat(x, 'g', -1, 64)
if !strings.ContainsAny(text, ".eE") {
text += ".0"
}
return parser.ParseValue(text)
}

// basicString renders a Go string as a quoted TOML basic string.
//
// The escaping is the scanner's own rather than Go's. Go's quoter writes a control character as \x07
// or \a and TOML defines neither, so such a value was refused with a diagnostic naming an offset into
// a string the operator never saw.
func basicString(s string) (string, error) {
if !utf8.ValidString(s) {
// The escaper substitutes a replacement rune for a byte that is not valid UTF-8, so writing one
// would store a different value than the caller passed and no error would say so.
return "", fmt.Errorf("the value is not valid UTF-8, and a configuration file holds text")
}
return `"` + string(scanner.Escape(s)) + `"`, nil
}

// quoteEach renders every element of a string list.
func quoteEach(ss []string) ([]string, error) {
out := make([]string, len(ss))
for i, s := range ss {
text, err := basicString(s)
if err != nil {
return nil, fmt.Errorf("element %d: %w", i, err)
}
out[i] = text
}
return out, nil
}
Loading
Loading