Tests were run against a private reference implementation: gate ok after every task in order, smoke ok (stream spread ~1000 ms). The reference is not in the repository. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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:
- Decoding.
toml.NewDecoder(r).Decode(&c). A TOML syntax error is returned asfmt.Errorf("config: %w", err)— it is not an*Error. Ifmd.Undecoded()is non-empty, the error is&Error{Field: <first undecoded key, keys sorted>, Msg: "unknown key"}.Loadwraps an open failure the same way (config: …). - Validation, in this order, first problem wins. Every problem is an
*Errorwith exactly thisField:listen: required; must behost: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) becomesDefaultPollInterval; less thanMinPollIntervalis an error.queue_max:0becomesDefaultQueueMax; negative is an error.hosts: at least one. Then for each host, in sorted name order:hosts.<name>.base_url:url.Parsemust succeed, schemehttporhttps, non-empty host, no query, no fragment. A trailing/is trimmed (strings.TrimRight(url, "/")) and the trimmed value stored.hosts.<name>.weight:0becomes1; negative is an error.hosts.<name>.models: at least one. For each model in sorted order,hosts.<name>.models.<model>.parallel:0becomes1; 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.
- Defaults are written back into the returned
Config(the tests readWeight == 1,Parallel == 1,PollInterval == DefaultPollIntervalafter parsing files that omit them). - 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.modwith 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 filesor undefined names). - 4. Write
internal/config/config.go. Rungofmt -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(Taskv0/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/isok;make gateprintsgate: ok.cmp internal/config/config_test.go docs/plans/v0/files/internal/config/config_test.goprints nothing.
Stop and report if
go mod downloadcannot fetch the module (no network): stop, logstopped.- A test expects a
Fieldyou cannot produce under the rules above: stop; do not edit the test.