Files
crossbar/docs/plans/v0/01-module-gate-config.md

6.3 KiB

v0 task 01: module, gate and the config package

Branch: v0 (create it: git switch -c v0; git status --short must be empty first, otherwise stop) Commit subject: Add the Go module, the gate and the config package

Goal

Create the Go module and the gate every later task must pass, and write internal/config: the TOML file crossbar starts from, with defaults and validation. At the end make gate prints gate: ok.

Context

crossbar is an HTTP reverse proxy in front of several llama-server routers ("hosts"). A client's identity is the first segment of its request path (its "route"); each route has an ordered list of hosts to try. The config file names the hosts, which models each serves, and the routes. It must be strict: a misspelt key or a route naming a host that does not exist is an error at start, not a surprise at 3 a.m. The one dependency is github.com/BurntSushi/toml v1.6.0; go.sum for it is given.

Files

  • Copy (never edit afterwards): Makefile, scripts/check-lines.sh, go.sum, internal/config/config_test.go, internal/config/testdata/ (5 files)
  • Create: go.mod, internal/config/config.go
  • Modify: docs/implementer-log.md

Interfaces

Produces, in internal/config/config.go, package config:

// Duration is a time.Duration that TOML reads from a string such as "60s" or "30m".
type Duration struct{ time.Duration }
func (d *Duration) UnmarshalText(text []byte) error        // time.ParseDuration

type Model struct { Parallel int `toml:"parallel"` }
type Host struct {
    BaseURL string           `toml:"base_url"`
    Weight  float64          `toml:"weight"`
    Models  map[string]Model `toml:"models"`
}
type Route struct {
    Hosts        []string `toml:"hosts"`
    DefaultModel string   `toml:"default_model"`
}
type Config struct {
    Listen       string           `toml:"listen"`
    PollInterval Duration         `toml:"poll_interval"`
    QueueMax     int              `toml:"queue_max"`
    Hosts        map[string]Host  `toml:"hosts"`
    Routes       map[string]Route `toml:"routes"`
}

// Error is a validation error naming the field it is about.
type Error struct { Field, Msg string }
func (e *Error) Error() string                              // "config: " + Field + ": " + Msg

const (
    DefaultPollInterval = 60 * time.Second
    DefaultQueueMax     = 8
    MinPollInterval     = time.Second
)

func Load(path string) (*Config, error)                    // open, then Parse
func Parse(r io.Reader) (*Config, error)                   // decode, defaults, validate
func (c *Config) Serves(host, model string) bool           // host exists and lists model
func IsError(err error) (*Error, bool)                      // errors.As on *Error

Rules the tests check:

  1. Decoding. toml.NewDecoder(r).Decode(&c). A TOML syntax error is returned as fmt.Errorf("config: %w", err) — it is not an *Error. If md.Undecoded() is non-empty, the error is &Error{Field: <first undecoded key, keys sorted>, Msg: "unknown key"}. Load wraps an open failure the same way (config: …).
  2. Validation, in this order, first problem wins. Every problem is an *Error with exactly this Field:
    • listen: required; must be host:port (net.SplitHostPort); the host part must not be empty and must not be an unspecified address (0.0.0.0, ::).
    • poll_interval: 0 (absent) becomes DefaultPollInterval; less than MinPollInterval is an error.
    • queue_max: 0 becomes DefaultQueueMax; negative is an error.
    • hosts: at least one. Then for each host, in sorted name order:
      • hosts.<name>.base_url: url.Parse must succeed, scheme http or https, non-empty host, no query, no fragment. A trailing / is trimmed (strings.TrimRight(url, "/")) and the trimmed value stored.
      • hosts.<name>.weight: 0 becomes 1; negative is an error.
      • hosts.<name>.models: at least one. For each model in sorted order, hosts.<name>.models.<model>.parallel: 0 becomes 1; negative is an error.
    • routes: at least one. For each route in sorted name order:
      • routes.<name>: the name must match ^[a-z0-9][a-z0-9-]*$.
      • routes.<name>.hosts: at least one; every entry must be a configured host; no host twice.
      • routes.<name>.default_model: if set, at least one of the route's hosts must list it.
  3. Defaults are written back into the returned Config (the tests read Weight == 1, Parallel == 1, PollInterval == DefaultPollInterval after parsing files that omit them).
  4. Nothing here panics on a bad file: every map lookup and slice access is on data you checked.

Steps

  • 1. Branch and copy.
git switch -c v0
mkdir -p scripts internal/config/testdata
cp docs/plans/v0/_files/Makefile docs/plans/v0/_files/go.sum .
cp docs/plans/v0/_files/scripts/check-lines.sh scripts/
cp docs/plans/v0/_files/internal/config/config_test.go internal/config/
cp docs/plans/v0/_files/internal/config/testdata/*.toml internal/config/testdata/

Read Makefile and internal/config/config_test.go. The test names every rule above.

  • 2. Write go.mod with exactly this content:
module git.wntrmute.dev/kyle/crossbar

go 1.26

require github.com/BurntSushi/toml v1.6.0

Then go mod download github.com/BurntSushi/toml (this needs the network once) and go mod verify. Expected: all modules verified.

  • 3. See the test fail. go test ./internal/config/. Expected: it does not compile (no Go files or undefined names).
  • 4. Write internal/config/config.go. Run gofmt -w internal/config/.
  • 5. See the test pass. go test ./internal/config/. Expected: ok.
  • 6. Run the gate. make gate. Expected last line: gate: ok.
  • 7. Log and commit. Add your row to docs/implementer-log.md (Task v0/01-module-gate-config).
git add go.mod go.sum Makefile scripts/check-lines.sh internal/config docs/implementer-log.md
git commit

Done when

  • go test ./internal/config/ is ok; make gate prints gate: ok.
  • cmp internal/config/config_test.go docs/plans/v0/_files/internal/config/config_test.go prints nothing.

Stop and report if

  • go mod download cannot fetch the module (no network): stop, log stopped.
  • A test expects a Field you cannot produce under the rules above: stop; do not edit the test.