mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-18 05:47:38 +00:00
ci: yaegi catalog-analyzer gate harness
Add tools/yaegi-check, a separate Go module (llmgw-yaegi-check,
never a dependency of the plugin module) that replicates the
Traefik Plugin Catalog analyzer (piceus)'s yaegiMiddlewareCheck:
copy the repo into a temp GOPATH, interp.New + i.Use(stdlib.Symbols),
import the plugin package, eval CreateConfig(), decode the plugin's
real .traefik.yml testData into it via a hand-rolled YAML-subset
parser plus a JSON round-trip, reflect-Call New from outside the
interpreter, then exercise the returned handler with two real HTTP
requests (authenticated GET /v1/models against the testData key, and
the same route unauthenticated). Pins github.com/traefik/yaegi to
v0.16.1, confirmed as Traefik v3.5.0's own vendored version by
downloading its go.mod.
Wire cd tools/yaegi-check && GOWORK=off go run . /Users/nvm/Documents/projects/private/traefik-llmgateway
yaegi-check: OK to run the harness from repo root.
The harness caught a real Yaegi v0.16.1 limitation: calling a struct
FIELD of variadic func type (e.g. modelRegistry.log
func(string, ...any)) with two or more variadic arguments crashes
Yaegi's CFG builder ('index out of range'). The identical call shape
through a method or interface method is unaffected — bisected with a
standalone yaegi repro to confirm the exact trigger before touching
plugin code. Fixed by pre-formatting with fmt.Sprintf at
modelRegistry's three affected call sites (registry.go) so the field
is always called with exactly one variadic argument; documented the
constraint on both modelRegistry.log and limiter.logf so it is not
reintroduced. Verified the gate itself is load-bearing by temporarily
renaming CreateConfig (harness failed non-zero) and restoring it
(harness passed again).
Also fixes two golangci-lint findings in the new harness module
(govet shadow, gosec G301 directory permissions).
This commit is contained in:
@@ -1,2 +1,3 @@
|
|||||||
.superpowers/
|
.superpowers/
|
||||||
*.wasm
|
*.wasm
|
||||||
|
tools/yaegi-check/llmgw-yaegi-check
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ lint:
|
|||||||
go vet ./...
|
go vet ./...
|
||||||
|
|
||||||
yaegi-check:
|
yaegi-check:
|
||||||
@echo "yaegi-check: placeholder until Task 14"
|
cd tools/yaegi-check && GOWORK=off go run . $(CURDIR)
|
||||||
|
|
||||||
integration:
|
integration:
|
||||||
@echo "integration: placeholder until Task 15"
|
@echo "integration: placeholder until Task 15"
|
||||||
|
|||||||
@@ -249,12 +249,19 @@ type limitViolation struct {
|
|||||||
// limiter enforces per-minute/day/month request, token, and cost limits
|
// limiter enforces per-minute/day/month request, token, and cost limits
|
||||||
// using fixed windows keyed by windowKey.
|
// using fixed windows keyed by windowKey.
|
||||||
type limiter struct {
|
type limiter struct {
|
||||||
store counterStore // configured backend; nil means always use fallback (see newLimiter)
|
store counterStore // configured backend; nil means always use fallback (see newLimiter)
|
||||||
fallback *memoryStore // in-process counter store, always available
|
fallback *memoryStore // in-process counter store, always available
|
||||||
nowFn func() time.Time // injected for tests; defaults to time.Now
|
nowFn func() time.Time // injected for tests; defaults to time.Now
|
||||||
logf func(format string, args ...any) // injectable store-error log; defaults to a no-op
|
// logf is a bound method value (g.errorf), injectable for tests; defaults
|
||||||
lastLogAt time.Time // guarded by logMu; last time a store error was logged
|
// to a no-op. Its own call site (logStoreError) passes exactly one
|
||||||
lastStoreFailure time.Time // guarded by logMu; zero means the store-down latch is not open (see storeLatched)
|
// variadic argument — never extend that to two or more without first
|
||||||
|
// reading modelRegistry.log's doc comment in registry.go: a struct
|
||||||
|
// field of variadic func type crashes Yaegi v0.16.1's CFG builder past
|
||||||
|
// one variadic argument, even though the identical call through a
|
||||||
|
// method or interface method does not.
|
||||||
|
logf func(format string, args ...any)
|
||||||
|
lastLogAt time.Time // guarded by logMu; last time a store error was logged
|
||||||
|
lastStoreFailure time.Time // guarded by logMu; zero means the store-down latch is not open (see storeLatched)
|
||||||
logMu sync.Mutex
|
logMu sync.Mutex
|
||||||
failOpen bool // store-error policy: true falls back to fallback, false refuses the request
|
failOpen bool // store-error policy: true falls back to fallback, false refuses the request
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-5
@@ -122,8 +122,18 @@ func (st *providerState) finishRefresh(now time.Time, ids []string, err error) {
|
|||||||
// guarded by its own mutex), so modelRegistry itself needs no lock beyond
|
// guarded by its own mutex), so modelRegistry itself needs no lock beyond
|
||||||
// warnedMu for the collision-log dedup below.
|
// warnedMu for the collision-log dedup below.
|
||||||
type modelRegistry struct {
|
type modelRegistry struct {
|
||||||
adapters map[string]providerAdapter
|
adapters map[string]providerAdapter
|
||||||
states map[string]*providerState
|
states map[string]*providerState
|
||||||
|
// log is a bound method value (g.errorf), injected rather than holding
|
||||||
|
// a *Gateway directly. Every call site below pre-formats with
|
||||||
|
// fmt.Sprintf and calls log("%s", msg) — never log(format, a, b, ...)
|
||||||
|
// with two or more variadic arguments. Yaegi v0.16.1's CFG builder
|
||||||
|
// panics ("index out of range") compiling a call to a struct FIELD of
|
||||||
|
// variadic func type with 2+ variadic arguments; the identical call
|
||||||
|
// shape through a method or interface method is unaffected — this is
|
||||||
|
// specific to a field holding a func value. Verified empirically
|
||||||
|
// against yaegi v0.16.1 (see tools/yaegi-check) and does not apply to
|
||||||
|
// g.logf/g.errorf, which are methods, not fields.
|
||||||
log func(string, ...any)
|
log func(string, ...any)
|
||||||
nowFn func() time.Time
|
nowFn func() time.Time
|
||||||
warned map[string]bool
|
warned map[string]bool
|
||||||
@@ -205,7 +215,7 @@ func (m *modelRegistry) warmFill(ctx context.Context) {
|
|||||||
cancel()
|
cancel()
|
||||||
st.finishRefresh(m.now(), ids, err)
|
st.finishRefresh(m.now(), ids, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.log("model registry: initial discovery for provider %q failed: %v", name, err)
|
m.log("%s", fmt.Sprintf("model registry: initial discovery for provider %q failed: %v", name, err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -264,7 +274,7 @@ func (m *modelRegistry) refreshProvider(name string, st *providerState, adapter
|
|||||||
}
|
}
|
||||||
st.finishRefresh(m.now(), ids, err)
|
st.finishRefresh(m.now(), ids, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.log("model registry: discovery refresh for provider %q failed: %v", name, err)
|
m.log("%s", fmt.Sprintf("model registry: discovery refresh for provider %q failed: %v", name, err))
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -371,7 +381,7 @@ func (m *modelRegistry) warnCollisionOnce(id, winner string, provs []string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
m.warned[id] = true
|
m.warned[id] = true
|
||||||
m.log("model registry: model id %q is provided by multiple providers %v; %q wins the bare id", id, provs, winner)
|
m.log("%s", fmt.Sprintf("model registry: model id %q is provided by multiple providers %v; %q wins the bare id", id, provs, winner))
|
||||||
}
|
}
|
||||||
|
|
||||||
// listFor returns grp's visible model catalog as OpenAI-compatible model
|
// listFor returns grp's visible model catalog as OpenAI-compatible model
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
module llmgw-yaegi-check
|
||||||
|
|
||||||
|
go 1.22
|
||||||
|
|
||||||
|
require github.com/traefik/yaegi v0.16.1
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
github.com/traefik/yaegi v0.16.1 h1:f1De3DVJqIDKmnasUF6MwmWv1dSEEat0wcpXhD2On3E=
|
||||||
|
github.com/traefik/yaegi v0.16.1/go.mod h1:4eVhbPb3LnD2VigQjhYbEJ69vDRFdT2HQNrXx8eEwUY=
|
||||||
@@ -0,0 +1,505 @@
|
|||||||
|
// 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, and
|
||||||
|
// VCS metadata 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user