Files
traefik-llmgateway/tools/yaegi-check/main.go
T
lukaszraczylo f7d9208bde chore(deps): vendor testify
Add github.com/stretchr/testify (test-only) as a direct dependency and
vendor it, matching the traefikoidc precedent: _test.go files are never
Yaegi-interpreted, so vendoring a test-only dependency does not affect the
plugin's runtime under Traefik or the catalog analyzer.

tools/yaegi-check/main.go: add vendor to the harness's repo-copy exclude
list, since vendor/'s nested module layout (go.yaml.in/yaml/v3 alongside
testify) has nothing to do with the plugin package Yaegi imports.

Verified: make yaegi-check stays green; go build/vet/test unaffected.
2026-08-20 21:31:42 +01:00

511 lines
16 KiB
Go

// Command yaegi-check replicates the Traefik Plugin Catalog analyzer
// (piceus)'s yaegiMiddlewareCheck for this plugin. It loads the plugin
// package under Yaegi exactly as the catalog and Traefik's own plugin
// loader do: interpret the source tree, evaluate CreateConfig(), decode
// the plugin's real .traefik.yml testData into the resulting Config
// value, then call the plugin's New constructor by reflection from
// outside the interpreter. It then exercises the returned handler with
// two real HTTP requests, proving ServeHTTP, auth, the model registry,
// statusTrackingWriter, and the gateway's logger all run under Yaegi —
// not merely that the package's imports resolve.
//
// Any failure — a Yaegi-incompatible construct in the plugin's stdlib
// usage, a constructor error, a nil handler, or a wrong response from the
// live handler — exits non-zero: this is the gate the plugin must pass
// before it can list in the Traefik Plugin Catalog.
//
// This module is intentionally separate from the plugin module
// (llmgw-yaegi-check, not github.com/lukaszraczylo/traefik-llmgateway):
// the plugin module must stay free of any non-stdlib dependency, since
// Yaegi interprets it with only the Go standard library available: a
// third-party import in the plugin module's own go.mod would break under
// interpretation even though `go build` never notices.
package main
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
"github.com/traefik/yaegi/interp"
"github.com/traefik/yaegi/stdlib"
)
// testDataUserAPIKey and testDataWantModel are drawn directly from the
// plugin's own .traefik.yml testData block (users.inline[0].apiKey and
// providers.openai.models[0]) — see exerciseHandler.
const (
testDataUserAPIKey = "test-user-key"
testDataWantModel = "gpt-test"
)
// excludedTopLevelDirs lists repo-root directories the GOPATH copy must
// never include: build tooling, integration fixtures, planning docs, VCS
// metadata, and vendor (task-8's test-only testify dependency — _test.go
// files are never interpreted by Yaegi, so vendor/ has nothing this check
// needs, and copying its "go.yaml.in"-style nested module dirs into a
// module-less GOPATH tree would otherwise confuse Yaegi's own import
// resolution) have nothing to do with the plugin package Yaegi imports.
var excludedTopLevelDirs = map[string]bool{
"tools": true,
"integration": true,
".superpowers": true,
"docs": true,
".git": true,
"vendor": true,
}
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "yaegi-check: FAIL:", err)
os.Exit(1)
}
fmt.Println("yaegi-check: OK")
}
// run resolves the plugin's repo root (os.Args[1], defaulting to the
// current directory), builds a temporary GOPATH containing a copy of its
// source, interprets the plugin package under Yaegi, and exercises the
// constructed handler. It returns the first error encountered, wrapped
// with enough context to diagnose which stage failed.
func run() error {
repoRoot := "."
if len(os.Args) > 1 {
repoRoot = os.Args[1]
}
repoRoot, err := filepath.Abs(repoRoot)
if err != nil {
return fmt.Errorf("resolve repo root: %w", err)
}
modulePath, err := readModulePath(filepath.Join(repoRoot, "go.mod"))
if err != nil {
return fmt.Errorf("read module path: %w", err)
}
gopath, err := os.MkdirTemp("", "llmgw-yaegi-check-*")
if err != nil {
return fmt.Errorf("create temp GOPATH: %w", err)
}
defer os.RemoveAll(gopath) //nolint:errcheck // best-effort cleanup of a temp dir
pkgDir := filepath.Join(gopath, "src", modulePath)
if err = copyRepoSource(repoRoot, pkgDir); err != nil {
return fmt.Errorf("copy repo source into GOPATH: %w", err)
}
pkgName, err := readPackageName(pkgDir)
if err != nil {
return fmt.Errorf("read plugin package name: %w", err)
}
i := interp.New(interp.Options{GoPath: gopath})
if err = i.Use(stdlib.Symbols); err != nil {
return fmt.Errorf("interp.Use(stdlib.Symbols): %w", err)
}
if _, err = i.Eval(`import "` + modulePath + `"`); err != nil {
return fmt.Errorf("import plugin package under yaegi: %w", err)
}
cfgVal, err := i.Eval(pkgName + ".CreateConfig()")
if err != nil {
return fmt.Errorf("call %s.CreateConfig() under yaegi: %w", pkgName, err)
}
if !cfgVal.CanInterface() {
return fmt.Errorf("%s.CreateConfig() result cannot be used via reflect.Value.Interface", pkgName)
}
testData, err := extractTraefikYAMLTestData(filepath.Join(repoRoot, ".traefik.yml"))
if err != nil {
return fmt.Errorf("extract .traefik.yml testData: %w", err)
}
testDataJSON, err := json.Marshal(testData)
if err != nil {
return fmt.Errorf("marshal testData: %w", err)
}
if err = json.Unmarshal(testDataJSON, cfgVal.Interface()); err != nil {
return fmt.Errorf("decode .traefik.yml testData into the interpreted Config: %w", err)
}
newVal, err := i.Eval(pkgName + ".New")
if err != nil {
return fmt.Errorf("resolve %s.New under yaegi: %w", pkgName, err)
}
if newVal.Kind() != reflect.Func {
return fmt.Errorf("%s.New is not a function (kind %s)", pkgName, newVal.Kind())
}
next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
results := newVal.Call([]reflect.Value{
reflect.ValueOf(context.Background()),
reflect.ValueOf(next),
cfgVal,
reflect.ValueOf("llmgw-check"),
})
if len(results) != 2 {
return fmt.Errorf("%s.New returned %d values, want 2 (http.Handler, error)", pkgName, len(results))
}
if errVal := results[1]; !errVal.IsNil() {
return fmt.Errorf("%s.New returned an error: %v", pkgName, errVal.Interface())
}
handlerVal := results[0]
if handlerVal.IsNil() {
return fmt.Errorf("%s.New returned a nil handler", pkgName)
}
handler, ok := handlerVal.Interface().(http.Handler)
if !ok {
return fmt.Errorf("%s.New's returned value does not implement http.Handler", pkgName)
}
return exerciseHandler(handler)
}
// exerciseHandler runs two real requests against the interpreted
// handler, drawn from the plugin's own .traefik.yml testData: an
// authenticated GET /v1/models, which must return the configured
// testDataWantModel; and the same route without credentials, which must
// be refused with 401 — proving ServeHTTP, auth.identify, the model
// registry, statusTrackingWriter, and the gateway's logger all execute
// for real under Yaegi, not merely that the package's imports resolve.
func exerciseHandler(handler http.Handler) error {
authedReq := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
authedReq.Header.Set("Authorization", "Bearer "+testDataUserAPIKey)
authedRec := httptest.NewRecorder()
handler.ServeHTTP(authedRec, authedReq)
if authedRec.Code != http.StatusOK {
return fmt.Errorf("GET /v1/models with the testData user key: status = %d, want 200, body=%s", authedRec.Code, authedRec.Body.String())
}
if !strings.Contains(authedRec.Body.String(), testDataWantModel) {
return fmt.Errorf("GET /v1/models body does not contain %q: %s", testDataWantModel, authedRec.Body.String())
}
unauthedReq := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
unauthedRec := httptest.NewRecorder()
handler.ServeHTTP(unauthedRec, unauthedReq)
if unauthedRec.Code != http.StatusUnauthorized {
return fmt.Errorf("GET /v1/models without a key: status = %d, want 401, body=%s", unauthedRec.Code, unauthedRec.Body.String())
}
return nil
}
// readModulePath returns the module path declared by goModPath's
// "module" directive.
func readModulePath(goModPath string) (string, error) {
f, err := os.Open(goModPath)
if err != nil {
return "", err
}
defer f.Close() //nolint:errcheck // read-side close; nothing actionable on failure
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if after, ok := strings.CutPrefix(line, "module "); ok {
return strings.TrimSpace(after), nil
}
}
if err := scanner.Err(); err != nil {
return "", err
}
return "", fmt.Errorf("no module directive found in %s", goModPath)
}
// readPackageName returns the package name declared by the first
// non-test .go file it finds directly inside dir. The plugin is a single
// root package, so any one of its files' declarations suffices.
func readPackageName(dir string) (string, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return "", err
}
for _, e := range entries {
name := e.Name()
if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
continue
}
pkgName, err := scanPackageDecl(filepath.Join(dir, name))
if err != nil {
return "", err
}
if pkgName != "" {
return pkgName, nil
}
}
return "", fmt.Errorf("no package declaration found in %s", dir)
}
// scanPackageDecl reads path's leading "package X" line, if any.
func scanPackageDecl(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close() //nolint:errcheck // read-side close; nothing actionable on failure
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if after, ok := strings.CutPrefix(line, "package "); ok {
return strings.TrimSpace(after), nil
}
}
return "", scanner.Err()
}
// copyRepoSource copies repoRoot into destDir, skipping every directory
// named in excludedTopLevelDirs. destDir plays the role of
// GOPATH/src/{modulePath} for the interpreter: Yaegi's source loader
// resolves the plugin's own import path by walking GOPATH exactly as the
// standard toolchain would for a pre-modules GOPATH-mode build.
func copyRepoSource(repoRoot, destDir string) error {
entries, err := os.ReadDir(repoRoot)
if err != nil {
return err
}
if err := os.MkdirAll(destDir, 0o750); err != nil {
return err
}
for _, e := range entries {
if excludedTopLevelDirs[e.Name()] {
continue
}
src := filepath.Join(repoRoot, e.Name())
dst := filepath.Join(destDir, e.Name())
if e.IsDir() {
if err := copyDir(src, dst); err != nil {
return err
}
continue
}
if err := copyFile(src, dst); err != nil {
return err
}
}
return nil
}
// copyDir recursively copies src to dst.
func copyDir(src, dst string) error {
return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(src, path)
if err != nil {
return err
}
target := filepath.Join(dst, rel)
if d.IsDir() {
return os.MkdirAll(target, 0o750)
}
return copyFile(path, target)
})
}
// copyFile copies src to dst, creating dst's parent directory if needed.
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close() //nolint:errcheck // read-side close; nothing actionable on failure
if err = os.MkdirAll(filepath.Dir(dst), 0o750); err != nil {
return err
}
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close() //nolint:errcheck // Close's error is checked explicitly below
if _, err := io.Copy(out, in); err != nil {
return err
}
return out.Close()
}
// yamlLine is one non-blank, non-comment line of a YAML file, with
// leading whitespace stripped and recorded as indent.
type yamlLine struct {
text string
indent int
}
// extractTraefikYAMLTestData reads path (the plugin's .traefik.yml) and
// decodes its top-level "testData:" block into a generic
// map[string]any/[]any/scalar tree, using a small hand-rolled,
// indentation-based YAML subset parser: just enough for this one file's
// shape (nested maps, lists of scalars, lists of flat maps, "{}"/"[]"
// empty collections), not a general YAML implementation. It exists only
// in this harness module — the plugin module never parses YAML itself,
// Traefik does that before handing the plugin its already-decoded Config.
func extractTraefikYAMLTestData(path string) (map[string]any, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
lines := tokenizeYAMLLines(string(raw))
block := extractTestDataBlock(lines)
if block == nil {
return nil, fmt.Errorf("%s has no top-level testData: block", path)
}
pos := 0
v := parseBlock(block, &pos, block[0].indent)
m, ok := v.(map[string]any)
if !ok {
return nil, fmt.Errorf("%s testData: block is not a map", path)
}
return m, nil
}
// tokenizeYAMLLines splits raw into non-blank, non-comment yamlLines,
// each carrying its own leading-space count as indent.
func tokenizeYAMLLines(raw string) []yamlLine {
var out []yamlLine
for _, ln := range strings.Split(raw, "\n") {
trimmedRight := strings.TrimRight(ln, " \t\r")
content := strings.TrimLeft(trimmedRight, " ")
if content == "" || strings.HasPrefix(content, "#") {
continue
}
out = append(out, yamlLine{indent: len(trimmedRight) - len(content), text: content})
}
return out
}
// extractTestDataBlock returns the lines nested under a top-level (indent
// 0) "testData:" line, or nil if lines has none.
func extractTestDataBlock(lines []yamlLine) []yamlLine {
for i, l := range lines {
if l.indent != 0 || l.text != "testData:" {
continue
}
var block []yamlLine
for j := i + 1; j < len(lines) && lines[j].indent > 0; j++ {
block = append(block, lines[j])
}
return block
}
return nil
}
// parseBlock parses the run of lines starting at *pos that share indent,
// dispatching to parseList when the first such line is a "-" item and to
// parseMap otherwise.
func parseBlock(lines []yamlLine, pos *int, indent int) any {
if *pos >= len(lines) || lines[*pos].indent != indent {
return map[string]any{}
}
if strings.HasPrefix(lines[*pos].text, "-") {
return parseList(lines, pos, indent)
}
return parseMap(lines, pos, indent)
}
// parseMap consumes every consecutive "key: value" line at indent,
// recursing into parseBlock for a key whose value is empty (a nested
// block follows on deeper-indented lines).
func parseMap(lines []yamlLine, pos *int, indent int) map[string]any {
m := map[string]any{}
for *pos < len(lines) && lines[*pos].indent == indent {
key, val, _ := strings.Cut(lines[*pos].text, ":")
key = strings.TrimSpace(key)
val = strings.TrimSpace(val)
*pos++
if val != "" {
m[key] = collectionOrScalar(val)
continue
}
if *pos < len(lines) && lines[*pos].indent > indent {
m[key] = parseBlock(lines, pos, lines[*pos].indent)
} else {
m[key] = map[string]any{}
}
}
return m
}
// parseList consumes every consecutive "- ..." line at indent. A "-"
// item containing a colon starts a flat map (its own "key: value" plus
// every following line indented at least indent+2, until a line back at
// indent or shallower); any other item is a bare scalar.
func parseList(lines []yamlLine, pos *int, indent int) []any {
var out []any
for *pos < len(lines) && lines[*pos].indent == indent && strings.HasPrefix(lines[*pos].text, "-") {
item := strings.TrimSpace(strings.TrimPrefix(lines[*pos].text, "-"))
if item == "" {
*pos++
continue
}
if !strings.Contains(item, ":") {
out = append(out, parseScalar(item))
*pos++
continue
}
m := map[string]any{}
key, val, _ := strings.Cut(item, ":")
m[strings.TrimSpace(key)] = collectionOrScalar(strings.TrimSpace(val))
*pos++
childIndent := indent + 2
for *pos < len(lines) && lines[*pos].indent >= childIndent {
k, v, _ := strings.Cut(lines[*pos].text, ":")
m[strings.TrimSpace(k)] = collectionOrScalar(strings.TrimSpace(v))
*pos++
}
out = append(out, m)
}
return out
}
// collectionOrScalar maps a "key: value" line's already-trimmed val to
// an empty map/slice for "{}"/"[]", or to parseScalar(val) otherwise. It
// does not recurse into a further nested block — no key in this file's
// testData needs a map/list value inside a list item's own fields, and
// this harness parses only what .traefik.yml actually contains.
func collectionOrScalar(val string) any {
switch val {
case "{}":
return map[string]any{}
case "[]":
return []any{}
default:
return parseScalar(val)
}
}
// parseScalar converts a YAML scalar token to a bool, int64, float64, or
// string, in that preference order, stripping a matching pair of quotes
// first.
func parseScalar(s string) any {
if len(s) >= 2 {
if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') {
return s[1 : len(s)-1]
}
}
switch s {
case "true":
return true
case "false":
return false
}
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
return n
}
if f, err := strconv.ParseFloat(s, 64); err == nil {
return f
}
return s
}