sxcli.devSimple Extensible CLI

Config migrations

Schemas change. Config files on disk do not — they sit in /etc on machines nobody wants to touch during an upgrade. The framework's answer is to make the schema's history part of the program: declare how each version becomes the next, and files written by any supported release keep loading forever.

Declaring the history#

Every config struct carries a version (see Your config struct). When the shape changes, keep the old shape as a type and add a step that converts it:

go
go
// the shape v1 files were written against — kept as history
type ConfigV1 struct {
    Version uint32 `json:"version"`
    Addr    string `json:"addr"`
}

// the shape today
type Config struct {
    Version uint32 `json:"version"`
    Listen  string `json:"listen" conf:"listen,l" usage:"address to serve on"`
}

fw.NewRegistration(ID,
    func() *Serve { return &Serve{cfg: Config{Version: 2, Listen: ":8080"}} },
    func(s *Serve) *Config { return &s.cfg },
).
    Alias("serve").
    Migrate(fw.Step(1, func(old ConfigV1) Config {
        return Config{Listen: old.Addr} // renamed field, carried across
    })).
    Register()

fw.Step(from, fn) says "a document at version from becomes the next version by running this function". Steps are listed oldest first, and the chain is checked at startup rather than trusted:

  • the from values must be contiguous,
  • each step's input type must be the previous step's output type,
  • the last step's output must be exactly the current config struct,
  • and the factory's default Version must be one past the last step.

A chain that skips a version, or ends on the wrong type, is a startup error naming the problem. You cannot ship a migration that doesn't actually reach the current schema.

What happens at load#

The version in the file decides:

Version in the fileWhat the framework does
the current versionloads normally, key by key
an older version with a chainparses as that version, walks the chain, loads the result
older than the oldest steperror: no longer supported, naming the oldest version that is
newer than the binaryerror: written by a newer schema than this binary
absentwarns, and treats the section as the current dialect

The newer-than-binary case is the one that saves an afternoon. A rolled back binary meeting a config file the newer release rewrote fails immediately and says so, instead of silently ignoring the keys it doesn't recognise.

Two details worth knowing. The old document is parsed strictly against its own version's type, so a key from v3 appearing in a document claiming v1 is an error rather than a value that quietly survives. And a migrated section is copied in whole, so a versioned document is treated as complete — its zero values shade your defaults rather than falling back to them. Migrated values are still only the file layer: environment and arguments override them as usual.

Upgrading the files themselves#

Migrations keep old files working, but you may want the file modernised on disk. --upgrade-config rewrites it in place, as a pure transform:

console
console
$ mytool --upgrade-config --config /etc/mytool/config.json

Pure means what it says — the environment and other arguments do not leak into the result, and sections belonging to other tools pass through verbatim. It runs against the whole catalog rather than one applet's closure, so a multi-applet binary upgrades every section it owns in one pass. An explicit --config target is required: there is no guessing which discovered file you meant to rewrite.

A section with no version key needs you to say what it was, since guessing would risk running the wrong conversion:

console
console
$ mytool --upgrade-config --config app.json --from-version mytool=1

Checking without running#

--validate-config runs every check the startup path would — schemas, domains, migrations, unknown keys — reports what it finds, and exits without running anything (0 clean, 2 violated). It belongs in a deployment pipeline, next to the config file it guards.

--help deliberately does not participate: it is best-effort, so a broken config file never takes the help output down with it. And for catching the same class of mistake before the binary exists at all, the sxcli.dev/vet analyser checks migration chains at compile time.

Next: Composition & the Builder — how a binary decides what it is made of.