The console gains live views of nodes, GPUs, models, replicas, usage and agent logs, and a settings tree over every key of server.yaml (agent.yaml on a node), generated from the configuration structs: values with their defaults and doc comments, edited in dialogs, saved only once the service's own loader accepts them, comments kept. The generator now tells blocks apart by package, which brings the TLS, engine and supervisor settings back into the reference. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
189 lines
5.5 KiB
Go
189 lines
5.5 KiB
Go
// Package docgen turns the configuration structs into reference documentation.
|
|
//
|
|
// Generated rather than written, because a hand-written reference is a promise
|
|
// that someone will remember. This project added six settings in a fortnight —
|
|
// store, servers, lease_ttl, thread and memory retention, min_disk_gb — and
|
|
// every one of them reached an operator only through a narrative document that
|
|
// happened to mention it. A reference nobody maintains is worse than none: it
|
|
// is the same list, quietly missing the setting you came to look up.
|
|
//
|
|
// Derived from the source, so the only way for it to be wrong is for the code
|
|
// to be wrong. A test regenerates it and fails when it has drifted, which is
|
|
// what keeps that true after everyone has forgotten this exists.
|
|
package docgen
|
|
|
|
//go:generate go run ./gen
|
|
|
|
import (
|
|
"fmt"
|
|
"go/ast"
|
|
"go/parser"
|
|
"go/token"
|
|
"reflect"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// Field is one configuration key.
|
|
type Field struct {
|
|
Key string
|
|
Type string
|
|
Doc string
|
|
// Nested names the struct this key expands into, by its ID
|
|
// (config.Auth), empty for a leaf. It is what lets the reference say
|
|
// "see Auth" rather than printing the same twenty keys under four
|
|
// parents.
|
|
Nested string
|
|
}
|
|
|
|
// Block is a struct that appears in configuration.
|
|
type Block struct {
|
|
// Package and Name are where the struct is declared: what tells
|
|
// tlsconf.Config from the agent's Config.
|
|
Package string
|
|
Name string
|
|
// Title is what it is called in the reference and on the screens: the
|
|
// name alone where no other block shares it, the package's name with it
|
|
// where one does.
|
|
Title string
|
|
Doc string
|
|
Fields []Field
|
|
}
|
|
|
|
// ID is the block's package and name, unique where Name is not.
|
|
func (b Block) ID() string { return b.Package + "." + b.Name }
|
|
|
|
// Collect reads koanf-tagged structs out of Go source files.
|
|
func Collect(files []string) ([]Block, error) {
|
|
fset := token.NewFileSet()
|
|
var blocks []Block
|
|
seen := map[string]bool{}
|
|
|
|
for _, path := range files {
|
|
f, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parsing %s: %w", path, err)
|
|
}
|
|
ast.Inspect(f, func(n ast.Node) bool {
|
|
ts, ok := n.(*ast.TypeSpec)
|
|
if !ok {
|
|
return true
|
|
}
|
|
st, ok := ts.Type.(*ast.StructType)
|
|
if !ok {
|
|
return true
|
|
}
|
|
b, ok := blockOf(f.Name.Name, ts, st)
|
|
if !ok || seen[b.ID()] {
|
|
return true
|
|
}
|
|
seen[b.ID()] = true
|
|
blocks = append(blocks, b)
|
|
return true
|
|
})
|
|
}
|
|
// Named by their own name unless another block has it too. Keyed by
|
|
// name alone, tlsconf.Config, engines.Config and supervisor.Config were
|
|
// each taken for the agent's Config and dropped: the reference had no
|
|
// TLS settings at all.
|
|
count := map[string]int{}
|
|
for _, b := range blocks {
|
|
count[b.Name]++
|
|
}
|
|
for i := range blocks {
|
|
blocks[i].Title = blocks[i].Name
|
|
if count[blocks[i].Name] > 1 {
|
|
blocks[i].Title = blocks[i].ID()
|
|
}
|
|
}
|
|
sort.Slice(blocks, func(i, j int) bool { return blocks[i].Title < blocks[j].Title })
|
|
return blocks, nil
|
|
}
|
|
|
|
// blockOf builds a Block, or reports that this struct carries no settings.
|
|
func blockOf(pkg string, ts *ast.TypeSpec, st *ast.StructType) (Block, bool) {
|
|
b := Block{Package: pkg, Name: ts.Name.Name, Doc: docText(ts.Doc)}
|
|
for _, f := range st.Fields.List {
|
|
if f.Tag == nil {
|
|
continue
|
|
}
|
|
tag := reflect.StructTag(strings.Trim(f.Tag.Value, "`"))
|
|
key, ok := tag.Lookup("koanf")
|
|
// "-" is a field carried in the struct but never read from a file;
|
|
// listing it would send someone looking for a setting that does not
|
|
// exist.
|
|
if !ok || key == "" || key == "-" {
|
|
continue
|
|
}
|
|
typ, nested := typeOf(pkg, f.Type)
|
|
b.Fields = append(b.Fields, Field{
|
|
Key: key, Type: typ, Doc: docText(f.Doc), Nested: nested,
|
|
})
|
|
}
|
|
return b, len(b.Fields) > 0
|
|
}
|
|
|
|
// typeOf renders a field's type, and names the struct it expands into.
|
|
func typeOf(pkg string, e ast.Expr) (rendered, nested string) {
|
|
switch t := e.(type) {
|
|
case *ast.Ident:
|
|
if isNamedStruct(t.Name) {
|
|
return t.Name, pkg + "." + t.Name
|
|
}
|
|
return t.Name, ""
|
|
case *ast.SelectorExpr:
|
|
// A type from another package, such as tlsconf.Config. Standard
|
|
// library types are values, not blocks: a time.Duration is a
|
|
// setting, written "30s".
|
|
name := t.Sel.Name
|
|
if x, ok := t.X.(*ast.Ident); ok {
|
|
if x.Name == "time" {
|
|
return x.Name + "." + name, ""
|
|
}
|
|
return x.Name + "." + name, x.Name + "." + name
|
|
}
|
|
return name, ""
|
|
case *ast.ArrayType:
|
|
inner, _ := typeOf(pkg, t.Elt)
|
|
return "[]" + inner, ""
|
|
case *ast.MapType:
|
|
k, _ := typeOf(pkg, t.Key)
|
|
v, _ := typeOf(pkg, t.Value)
|
|
return "map[" + k + "]" + v, ""
|
|
case *ast.StarExpr:
|
|
inner, n := typeOf(pkg, t.X)
|
|
return "*" + inner, n
|
|
default:
|
|
return "value", ""
|
|
}
|
|
}
|
|
|
|
// isNamedStruct guesses whether an identifier names a configuration block.
|
|
//
|
|
// By capitalisation, because the alternative is resolving types across
|
|
// packages for a document. A wrong guess costs a "see also" that points at
|
|
// nothing, which the renderer drops.
|
|
func isNamedStruct(name string) bool {
|
|
if name == "" {
|
|
return false
|
|
}
|
|
switch name {
|
|
case "string", "bool", "int", "int64", "float64", "any", "byte", "rune":
|
|
return false
|
|
}
|
|
return name[0] >= 'A' && name[0] <= 'Z'
|
|
}
|
|
|
|
// docText flattens a comment group into one paragraph per blank line.
|
|
func docText(g *ast.CommentGroup) string {
|
|
if g == nil {
|
|
return ""
|
|
}
|
|
var lines []string
|
|
for _, c := range g.List {
|
|
t := strings.TrimPrefix(c.Text, "//")
|
|
lines = append(lines, strings.TrimPrefix(t, " "))
|
|
}
|
|
return strings.TrimSpace(strings.Join(lines, "\n"))
|
|
}
|