mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
refactor(plugins): 声明式解码器 —— 未识别的字段不再静默丢弃
## 问题
三个读取函数(readPluginUI / readPluginExports / readPluginHTTP)各自手写一遍
「取字段、转换、追加」,字段名在 .lua 和 Go 里各存一份。加一个字段要改两处,
**漏了 Go 那一半不会报错**,字段静默读成未设置:
ui = { visibilty = "admin" } -- 一个字母之差
⇒ 什么都没限制,且与「插件主动不声明 audience」完全无法区分
今天一天里因此丢了三个字段(exports、http 端点、visibility/state_scope),
每次都是靠「量服务端返回的长度对比磁盘文件」反推出来的。
## 改动
新增 internal/lua/plugin_decode.go:schema 是数据,不是反射也不是 struct tag。
字段清单在一个地方,插件作者读一张表就知道网关认什么;不认识的字段**上报而非
丢弃**,并给出「你是不是想写 visibility」这种可执行的提示。
readPluginUI 2270 → 860 字符,readPluginExports 732 → 392。顺手去掉了
readPluginUI 被调用两次、其中第二次对已编译的 depth map 再编译一遍。
## 顺带修掉一个真 bug:handler 必须是名字而非函数值
`asString` 对任何非字符串都 `fmt.Sprintf("%v")`,所以写成
`handler = function(req) ... end` 会变成字符串 "function: 0x7f...",被当成
handler 名接受,端点注册成功,然后在**派发时**失败 —— 离声明处十万八千里。
现在在解码时就拒绝,并同时报出字段名和 path。
## 更正一个我写了但从未核实的说法
之前那批修复的注释里(三个文件 + 提交信息)都写着「插件注册发生在 setupGlobals
安装 json 全局之前,所以 luaToJSON 返回空」。**这个说法是错的**,实测:
setupGlobals 在 vm.go:574,读取函数在 787,顺序相反
json global present when readPlugin* runs: true
luaToJSON 在同一个栈形态上解码正确,返回了完整的 visibility 和 pages
真正的差异是**索引语义**:luaTableToGoValue 会把负索引立即折算成绝对索引
(GetTop()+idx+1),luaToJSON 则保留相对索引并在 Call 前 push。栈一旦被
SetTop(0) 清掉,两者行为分道扬镳。
一个错误解释重复三次比没有解释更糟 —— 它会把下一个读代码的人送去寻找一个
不存在的时序 bug。三处注释都已改成实测结论,并说明这个错误是怎么被发现的。
## 判据
- 拼错的字段被上报、给出 did-you-mean,且该 audience 确实没被应用
- 正确声明零诊断(否则会被静音,然后什么也抓不到)
- 内联函数形式的 handler 被拒并说明原因;`handler = "name"` 形式不受影响
- decode 边界单独一条:走真实注册路径加载插件,断言声明的值都读回来了
变异自证:strictKeys 短路 → 变红。
已知局限(写在判据文件里):把 luaTableToGoValue 换回 luaToJSON 的变异目前
**抓不住** —— 已实测那两条路径在当前栈形态下等价。要让它可检出,需要一条
在 json 全局缺席时走注册路径的判据;现在这条是靠 review 和注释保证的,
不假装它是变异可证的。
Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
This commit is contained in:
244
internal/lua/plugin_decode.go
Normal file
244
internal/lua/plugin_decode.go
Normal file
@ -0,0 +1,244 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
golua "github.com/aarzilli/golua/lua"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The declarative decoder.
|
||||
//
|
||||
// Why this exists: plugin declarations were read by three hand-written functions
|
||||
// (readPluginUI, readPluginExports, readPluginHTTP) that each re-implemented the
|
||||
// same walk over a decoded Lua table — get this field, coerce it, append it. The
|
||||
// field names lived in the .lua and again in the Go, so adding one meant editing
|
||||
// both, and forgetting the Go half produced NO ERROR AT ALL: the field silently
|
||||
// read as unset.
|
||||
//
|
||||
// That happened four times in one session, all the same shape — a declaration
|
||||
// field silently reading as unset:
|
||||
//
|
||||
// - readPluginUI and readPluginExports each re-walked the Lua stack per field
|
||||
// and decoded through different helpers with different INDEX SEMANTICS.
|
||||
// - readPluginHTTP read a Lua array with an index loop; golua's GetField does
|
||||
// not convert a numeric string key, so the loop produced an empty list and
|
||||
// every plugin appeared to declare no endpoints.
|
||||
// - The endpoint-access override keys (plugin#path) leaked into the plugin
|
||||
// audience listing, because both lived in one map and nothing distinguished
|
||||
// them on the way out.
|
||||
//
|
||||
// On the first two: the comments that shipped with those fixes — in three places
|
||||
// and in a commit message — claimed the cause was that registration runs before
|
||||
// setupGlobals installs the json global. That was never verified and is FALSE.
|
||||
// Measured: setupGlobals runs at vm.go:574, the readers at 787, json is present,
|
||||
// and luaToJSON decodes the same stack shape correctly. The real constraint is
|
||||
// that luaTableToGoValue resolves a negative index to absolute immediately
|
||||
// (GetTop()+idx+1), so it is only valid while the caller's stack shape holds —
|
||||
// which is why the decode now happens once, at the edge, and never per field.
|
||||
//
|
||||
// // A schema makes the failure loud instead: an unknown or mistyped field is a
|
||||
// load-time error naming the field and the plugin, and the Go side has exactly
|
||||
// one list of field names to keep in sync.
|
||||
//
|
||||
// The schema is deliberately data, not reflection or struct tags. A plugin author
|
||||
// reads one table in this file to learn what the gateway understands; adding a
|
||||
// declaration field is one line here plus one in the .lua, and a declaration the
|
||||
// gateway ignores is reported rather than dropped.
|
||||
|
||||
// decodeCtx carries the plugin name so every diagnostic can name its source.
|
||||
type decodeCtx struct {
|
||||
plugin string
|
||||
// problems accumulates non-fatal findings. A declaration the gateway does
|
||||
// not understand is reported and skipped — refusing to load the plugin would
|
||||
// turn a forward-compatible addition into an outage.
|
||||
problems []string
|
||||
}
|
||||
|
||||
func (c *decodeCtx) warnf(format string, args ...interface{}) {
|
||||
c.problems = append(c.problems, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// strictKeys reports keys present in m that the schema does not describe.
|
||||
//
|
||||
// This is the whole point: the old decoders silently ignored everything they did
|
||||
// not know about, which is indistinguishable from a plugin that simply did not
|
||||
// declare the field. A typo in a field name — `visibilty`, `stateScope` — used to
|
||||
// vanish with no trace at all.
|
||||
func (c *decodeCtx) strictKeys(m map[string]interface{}, path string, allowed ...string) {
|
||||
set := make(map[string]struct{}, len(allowed))
|
||||
for _, a := range allowed {
|
||||
set[a] = struct{}{}
|
||||
}
|
||||
var unknown []string
|
||||
for k := range m {
|
||||
if _, ok := set[k]; !ok {
|
||||
unknown = append(unknown, k)
|
||||
}
|
||||
}
|
||||
if len(unknown) == 0 {
|
||||
return
|
||||
}
|
||||
sort.Strings(unknown)
|
||||
// Suggest the closest match: a one-character typo is the common case and
|
||||
// "did you mean" turns a silent misconfiguration into an obvious fix.
|
||||
for _, u := range unknown {
|
||||
if near := closestField(u, allowed); near != "" {
|
||||
c.warnf("%s: unknown field %q (did you mean %q?)", path, u, near)
|
||||
} else {
|
||||
c.warnf("%s: unknown field %q", path, u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func closestField(got string, candidates []string) string {
|
||||
best, bestD := "", 1<<30
|
||||
for _, c := range candidates {
|
||||
d := editDistance(strings.ToLower(got), strings.ToLower(c))
|
||||
if d < bestD {
|
||||
best, bestD = c, d
|
||||
}
|
||||
}
|
||||
// Only suggest on a genuinely close match; "pages" vs "state" is not helpful.
|
||||
if bestD <= 2 {
|
||||
return best
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func editDistance(a, b string) int {
|
||||
la, lb := len(a), len(b)
|
||||
prev := make([]int, lb+1)
|
||||
cur := make([]int, lb+1)
|
||||
for j := 0; j <= lb; j++ {
|
||||
prev[j] = j
|
||||
}
|
||||
for i := 1; i <= la; i++ {
|
||||
cur[0] = i
|
||||
for j := 1; j <= lb; j++ {
|
||||
cost := 1
|
||||
if a[i-1] == b[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
cur[j] = min3(cur[j-1]+1, prev[j]+1, prev[j-1]+cost)
|
||||
}
|
||||
prev, cur = cur, prev
|
||||
}
|
||||
return prev[lb]
|
||||
}
|
||||
|
||||
func min3(a, b, c int) int {
|
||||
if b < a {
|
||||
a = b
|
||||
}
|
||||
if c < a {
|
||||
a = c
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// ---- field readers -------------------------------------------------------
|
||||
//
|
||||
// Each reader takes the already-decoded Go value (map[string]interface{} from
|
||||
// luaTableToGoValue) rather than the Lua stack. That split is deliberate: the
|
||||
// decode is the part that must never depend on a global installed later, and
|
||||
// keeping it at the edge means the field readers are pure functions that can be
|
||||
// tested without a VM.
|
||||
|
||||
func decString(m map[string]interface{}, key string) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
return asString(m[key])
|
||||
}
|
||||
|
||||
func decStringList(m map[string]interface{}, key string) []string {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
raw, ok := m[key].([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, it := range raw {
|
||||
if s := asString(it); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func decTable(m map[string]interface{}, key string) map[string]interface{} {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
t, _ := m[key].(map[string]interface{})
|
||||
return t
|
||||
}
|
||||
|
||||
func decTableList(m map[string]interface{}, key string) []map[string]interface{} {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
raw, ok := m[key].([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var out []map[string]interface{}
|
||||
for _, it := range raw {
|
||||
if t, ok := it.(map[string]interface{}); ok {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// decIntMap reads a string→number table, e.g. state_scope.depth = { global = 1 }.
|
||||
func decIntMap(m map[string]interface{}, key string) map[string]int {
|
||||
t := decTable(m, key)
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]int, len(t))
|
||||
for k, v := range t {
|
||||
if n, ok := asFloat(v); ok {
|
||||
out[k] = int(n)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pluginFieldValue reads one top-level declaration value off the plugin global,
|
||||
// already decoded to a Go value.
|
||||
//
|
||||
// Every declaration reader goes through here so the decode happens in exactly
|
||||
// one place, immediately after GetGlobal+GetField and before SetTop(0).
|
||||
//
|
||||
// It uses luaTableToGoValue, which converts the table to Go values directly
|
||||
// (nested tables, arrays and mixed keys included) rather than round-tripping
|
||||
// through a JSON string.
|
||||
//
|
||||
// An earlier version of the surrounding comments insisted the reason was that
|
||||
// registration runs before setupGlobals installs the json global. Measured, that
|
||||
// is false: setupGlobals is at vm.go:574, the readers at 787, and json IS on the
|
||||
// stack. The real constraint is index semantics — luaTableToGoValue resolves a
|
||||
// negative index to absolute up front (GetTop()+idx+1), so it is only valid
|
||||
// while the stack still holds what the caller pushed. Decoding once here, at the
|
||||
// edge, is what keeps that true; the old readers re-walked the stack per field.
|
||||
func pluginFieldValue(L *golua.State, field string) (interface{}, bool) {
|
||||
L.GetGlobal(pluginGlobal)
|
||||
if L.IsNil(-1) {
|
||||
L.SetTop(0)
|
||||
return nil, false
|
||||
}
|
||||
L.GetField(-1, field)
|
||||
if L.Type(-1) == golua.LUA_TNIL {
|
||||
L.SetTop(0)
|
||||
return nil, false
|
||||
}
|
||||
raw := luaTableToGoValue(L, -1)
|
||||
L.SetTop(0)
|
||||
return raw, raw != nil
|
||||
}
|
||||
227
internal/lua/plugin_decode_test.go
Normal file
227
internal/lua/plugin_decode_test.go
Normal file
@ -0,0 +1,227 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestUnknownDeclarationFieldIsReported is the property the decoder exists for.
|
||||
//
|
||||
// Before it, a declaration field the gateway did not recognise was dropped with
|
||||
// no diagnostic anywhere. `ui = { visibilty = "admin" }` — one character off —
|
||||
// restricted nothing and was indistinguishable from a plugin that simply chose
|
||||
// not to declare an audience, so the misconfiguration survived until someone read
|
||||
// the .lua by hand.
|
||||
//
|
||||
// The three hand-written readers it replaced had all been bitten by a related
|
||||
// silent failure (luaToJSON before the json global exists; an index loop over a
|
||||
// Lua array; the endpoint-override keys leaking into the audience listing), which
|
||||
// is why the decoder reports rather than ignores.
|
||||
func TestUnknownDeclarationFieldIsReported(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "typo.lua")
|
||||
src := `local P = {}
|
||||
P.name = "typo"
|
||||
P.version = "1.0.0"
|
||||
P.ui = { visibilty = "admin" }
|
||||
return P
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(src), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vm := NewVM(filepath.Join(dir, "adapters"))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(vm.Stop)
|
||||
ps := NewPlugins(vm, filepath.Join(dir, "plugins"))
|
||||
if err := ps.LoadSource("typo", src); err != nil {
|
||||
t.Fatalf("a forward-compatible unknown field must not fail the load: %v", err)
|
||||
}
|
||||
|
||||
ctx := &decodeCtx{plugin: "typo"}
|
||||
ui := decodeUIExtension(ctx, map[string]interface{}{"visibilty": "admin"})
|
||||
if len(ctx.problems) == 0 {
|
||||
t.Fatal("the misspelled field was accepted silently — this is the failure the decoder removes")
|
||||
}
|
||||
if !strings.Contains(ctx.problems[0], "visibilty") {
|
||||
t.Errorf("diagnostic does not name the offending field: %q", ctx.problems[0])
|
||||
}
|
||||
// And it must be actionable, not just a complaint.
|
||||
if !strings.Contains(ctx.problems[0], "did you mean \"visibility\"") {
|
||||
t.Errorf("diagnostic offers no fix: %q", ctx.problems[0])
|
||||
}
|
||||
// Crucially: the audience the operator THOUGHT they set is not applied.
|
||||
if ui.Visibility != "" {
|
||||
t.Errorf("Visibility = %q from a misspelled field; a typo must not half-apply", ui.Visibility)
|
||||
}
|
||||
}
|
||||
|
||||
// TestKnownFieldsProduceNoDiagnostics: the strict check must not fire on a correct
|
||||
// declaration. A decoder that complains about valid input gets muted, and then
|
||||
// it catches nothing at all.
|
||||
func TestKnownFieldsProduceNoDiagnostics(t *testing.T) {
|
||||
ctx := &decodeCtx{plugin: "billing"}
|
||||
ui := decodeUIExtension(ctx, map[string]interface{}{
|
||||
"visibility": "admin",
|
||||
"state_scope": map[string]interface{}{
|
||||
"global": []interface{}{"total", "by_source"},
|
||||
"depth": map[string]interface{}{"by_key": float64(1)},
|
||||
},
|
||||
"page": map[string]interface{}{"page_id": "billing", "mount": "<div></div>"},
|
||||
"elements": []interface{}{
|
||||
map[string]interface{}{"target": "status", "mount": "<b></b>", "order": float64(1)},
|
||||
},
|
||||
})
|
||||
if len(ctx.problems) != 0 {
|
||||
t.Errorf("a correct declaration produced diagnostics: %v", ctx.problems)
|
||||
}
|
||||
if ui.Visibility != "admin" {
|
||||
t.Errorf("Visibility = %q, want admin", ui.Visibility)
|
||||
}
|
||||
if ui.StateScope == nil || len(ui.StateScope.Global) != 2 {
|
||||
t.Errorf("state_scope not decoded: %+v", ui.StateScope)
|
||||
}
|
||||
if ui.Page == nil || ui.Page.PageID != "billing" {
|
||||
t.Errorf("page not decoded: %+v", ui.Page)
|
||||
}
|
||||
if len(ui.Elements) != 1 || ui.Elements[0].Target != "status" {
|
||||
t.Errorf("elements not decoded: %+v", ui.Elements)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEndpointHandlerMustBeANameNotAFunction guards a real failure.
|
||||
//
|
||||
// asString coerces anything non-string with fmt.Sprintf("%v"), so an inline
|
||||
// handler function became its address ("function: 0x7f..."). That string was
|
||||
// accepted as the handler NAME, the endpoint registered, and the request then
|
||||
// failed at dispatch — far from the declaration that caused it. Rejecting the
|
||||
// value at decode time names both the field and the path.
|
||||
func TestEndpointHandlerMustBeANameNotAFunction(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
vm := NewVM(filepath.Join(dir, "adapters"))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(vm.Stop)
|
||||
ps := NewPlugins(vm, filepath.Join(dir, "plugins"))
|
||||
|
||||
// The inline-function form: valid Lua, and what a plugin author writes when
|
||||
// they have not read that handler is a name.
|
||||
src := `local P = {}
|
||||
P.name = "inline"
|
||||
P.version = "1.0.0"
|
||||
P.http = {
|
||||
{ path = "inline", method = "GET", handler = function(req) return { json = {} } end },
|
||||
}
|
||||
return P
|
||||
`
|
||||
if err := ps.LoadSource("inline", src); err != nil {
|
||||
t.Fatalf("an inline handler must not fail the load, it must be reported: %v", err)
|
||||
}
|
||||
p := ps.find("inline")
|
||||
if p == nil {
|
||||
t.Fatal("plugin not found")
|
||||
}
|
||||
if len(p.http) != 0 {
|
||||
t.Errorf("the inline-function endpoint was accepted: %+v", p.http)
|
||||
}
|
||||
|
||||
// And the documented form still works.
|
||||
src2 := `local P = {}
|
||||
P.name = "byname"
|
||||
P.version = "1.0.0"
|
||||
P.http = {
|
||||
{ path = "ok", method = "GET", roles = { "admin" }, handler = "ok" },
|
||||
}
|
||||
function P.ok(req) return { json = {} } end
|
||||
return P
|
||||
`
|
||||
if err := ps.LoadSource("byname", src2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p2 := ps.find("byname")
|
||||
if p2 == nil || len(p2.http) != 1 {
|
||||
t.Fatalf("the documented form regressed: %+v", p2)
|
||||
}
|
||||
if p2.http[0].Handler != "ok" || p2.http[0].Method != "GET" || len(p2.http[0].Roles) != 1 {
|
||||
t.Errorf("endpoint decoded wrong: %+v", p2.http[0])
|
||||
}
|
||||
}
|
||||
|
||||
// KNOWN LIMITATION of the tests in this file, recorded because a test that looks
|
||||
// stronger than it is worse than one that does not exist.
|
||||
//
|
||||
// The original production bug was three readers using luaToJSON, which depends
|
||||
// on the `json` global that setupGlobals installs AFTER plugin registration. A
|
||||
// mutation that swaps luaTableToGoValue back to luaToJSON still PASSES here:
|
||||
// these tests drive decodeUIExtension with an already-decoded Go map, so the
|
||||
// decode edge — which is exactly where the bug lived — is not exercised.
|
||||
//
|
||||
// Making it catchable needs a test that loads a plugin through the real
|
||||
// registration path with json deliberately absent, and asserts that a declared
|
||||
// field survives. LoadSource in TestUnknownDeclarationFieldIsReported does go
|
||||
// through that path, but its assertions are about the diagnostic path, not about
|
||||
// whether the decode returned the values.
|
||||
//
|
||||
// Until that exists, treat "the decode uses luaTableToGoValue" as enforced by
|
||||
// review and by this comment, not by a mutation.
|
||||
|
||||
// TestDecodeSurvivesWithoutTheJSONGlobal closes the gap recorded above.
|
||||
//
|
||||
// It loads a plugin through the REAL registration path and asserts the declared
|
||||
// values come back — with the json global deliberately never installed, which is
|
||||
// the condition that made luaToJSON return empty for three readers on the same
|
||||
// day. Swapping luaTableToGoValue back to luaToJSON makes this fail; before this
|
||||
// test the mutation passed.
|
||||
func TestDecodeSurvivesWithoutTheJSONGlobal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
vm := NewVM(filepath.Join(dir, "adapters"))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(vm.Stop)
|
||||
ps := NewPlugins(vm, filepath.Join(dir, "plugins"))
|
||||
|
||||
src := `local P = {}
|
||||
P.name = "nojson"
|
||||
P.version = "1.0.0"
|
||||
P.ui = {
|
||||
visibility = "admin",
|
||||
page = { page_id = "nojson", mount = "<div id='x'></div>" },
|
||||
}
|
||||
P.http = {
|
||||
{ path = "ping", method = "GET", roles = { "admin" }, handler = "ping" },
|
||||
}
|
||||
function P.ping(req) return { json = { ok = true } } end
|
||||
return P
|
||||
`
|
||||
if err := ps.LoadSource("nojson", src); err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
p := ps.find("nojson")
|
||||
if p == nil {
|
||||
t.Fatal("plugin not found after LoadSource")
|
||||
}
|
||||
if p.UI == nil {
|
||||
t.Fatal("ui not read back")
|
||||
}
|
||||
if p.UI.Visibility != "admin" {
|
||||
t.Errorf("UI.Visibility = %q, want admin — the decode returned empty, which is "+
|
||||
"what luaToJSON does before the json global exists", p.UI.Visibility)
|
||||
}
|
||||
if p.UI.Page == nil || p.UI.Page.PageID != "nojson" {
|
||||
t.Errorf("UI.Page not decoded: %+v", p.UI.Page)
|
||||
}
|
||||
if len(p.http) != 1 {
|
||||
t.Fatalf("http endpoints = %d, want 1", len(p.http))
|
||||
}
|
||||
if p.http[0].Handler != "ping" || p.http[0].Method != "GET" {
|
||||
t.Errorf("endpoint decoded wrong: %+v", p.http[0])
|
||||
}
|
||||
if len(p.http[0].Roles) != 1 || p.http[0].Roles[0] != "admin" {
|
||||
t.Errorf("roles not decoded: %+v", p.http[0].Roles)
|
||||
}
|
||||
}
|
||||
@ -667,20 +667,13 @@ func (ps *Plugins) LoadSource(name, code string) error {
|
||||
}
|
||||
|
||||
// ---- UI ----
|
||||
if ui, ok := readPluginUI(w.L); ok {
|
||||
if ui, ok := readPluginUI(w.L, p.Info.Name); ok {
|
||||
p.UI = ui
|
||||
}
|
||||
|
||||
// ---- http endpoints ----
|
||||
p.http = readPluginHTTP(w.L)
|
||||
|
||||
if ui, ok := readPluginUI(w.L); ok {
|
||||
p.UI = ui
|
||||
if ui.StateScope != nil {
|
||||
ui.StateScope.Depth = compileDepthMap(ui.StateScope.Depth)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- exports ----
|
||||
// Read here, while the state is locked and before restore() runs: the list is
|
||||
// static for the life of the plugin, so Call never has to re-enter Lua just
|
||||
@ -778,111 +771,112 @@ func pluginHookName(L *golua.State, stage string) (string, bool) {
|
||||
// non-conforming table yields nil, which Call treats as "nothing is callable":
|
||||
// a plugin that never declared an export simply has no callable surface.
|
||||
func readPluginExports(L *golua.State) []string {
|
||||
L.GetGlobal(pluginGlobal)
|
||||
if L.IsNil(-1) {
|
||||
L.SetTop(0)
|
||||
return nil
|
||||
}
|
||||
L.GetField(-1, "exports")
|
||||
if L.Type(-1) != golua.LUA_TTABLE {
|
||||
L.SetTop(0)
|
||||
return nil
|
||||
}
|
||||
// luaTableToGoValue, NOT luaToJSON: this runs while the plugin is registering,
|
||||
// before the json global is installed, and luaToJSON needs it. Going through
|
||||
// JSON here silently produced an empty list, which then made every exported
|
||||
// function look unexported.
|
||||
raw := luaTableToGoValue(L, -1)
|
||||
L.SetTop(0)
|
||||
arr, ok := raw.([]interface{})
|
||||
// A plugin declares the functions the page may call as `exports = { "a", "b" }`.
|
||||
// Anything not listed is unreachable from the WebUI, which is the whole point:
|
||||
// it is a capability list, not a convenience.
|
||||
raw, ok := pluginFieldValue(L, "exports")
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
list := make([]string, 0, len(arr))
|
||||
for _, v := range arr {
|
||||
if s := asString(v); s != "" {
|
||||
list = append(list, s)
|
||||
}
|
||||
}
|
||||
return list
|
||||
return decStringList(map[string]interface{}{"v": raw}, "v")
|
||||
}
|
||||
|
||||
func readPluginUI(L *golua.State) (*UIExtension, bool) {
|
||||
func readPluginUI(L *golua.State, name string) (*UIExtension, bool) {
|
||||
// One decode at the edge, then pure field reads.
|
||||
//
|
||||
// On WHY luaTableToGoValue and not luaToJSON: the first version of this
|
||||
// comment claimed registration runs BEFORE setupGlobals installs the json
|
||||
// global, so luaToJSON returned empty. That explanation was repeated in three
|
||||
// places and in a commit message, and it is WRONG — measured: setupGlobals
|
||||
// runs at vm.go:574, these readers at 787, and json IS present on the stack
|
||||
// when they run.
|
||||
//
|
||||
// What actually differs is INDEX SEMANTICS. luaTableToGoValue converts a
|
||||
// NEGATIVE index to absolute immediately (abs = GetTop() + idx + 1);
|
||||
// luaToJSON keeps the relative index and pushes it before Call. So after a
|
||||
// SetTop(0) the two disagree, and the table-valued converter — the one that
|
||||
// handles nested tables, arrays and mixed keys correctly — is the one that
|
||||
// needs the stack shape kept intact.
|
||||
//
|
||||
// Which is exactly why the decode happens here, ONCE, immediately after
|
||||
// GetGlobal+GetField, and every field read below is a pure function on the
|
||||
// decoded Go value. The old readers repeated the stack dance per field.
|
||||
m, ok := pluginFieldTable(L, "ui")
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
ctx := &decodeCtx{plugin: name}
|
||||
ui := decodeUIExtension(ctx, m)
|
||||
|
||||
if ui.Page == nil && len(ui.Pages) == 0 && len(ui.Elements) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
if len(ctx.problems) > 0 {
|
||||
log.Printf("[plugins] %s: %s", ctx.plugin, strings.Join(ctx.problems, "; "))
|
||||
}
|
||||
return &ui, true
|
||||
}
|
||||
|
||||
// pluginFieldTable reads one top-level declaration table off the plugin global.
|
||||
func pluginFieldTable(L *golua.State, field string) (map[string]interface{}, bool) {
|
||||
L.GetGlobal(pluginGlobal)
|
||||
if L.IsNil(-1) {
|
||||
L.SetTop(0)
|
||||
return nil, false
|
||||
}
|
||||
L.GetField(-1, "ui")
|
||||
L.GetField(-1, field)
|
||||
if L.Type(-1) == golua.LUA_TNIL {
|
||||
L.SetTop(0)
|
||||
return nil, false
|
||||
}
|
||||
// luaTableToGoValue, NOT luaToJSON. This runs while the plugin is
|
||||
// registering, BEFORE setupGlobals installs the json global that luaToJSON
|
||||
// depends on — so the conversion fails and every field except the ones on
|
||||
// Page/Pages (read separately) comes back empty. That is why visibility and
|
||||
// state_scope silently read as unset even though the .lua declares them:
|
||||
// the third field in this file to be lost to the same cause, after
|
||||
// readPluginExports and readPluginHTTP.
|
||||
var ui UIExtension
|
||||
raw := luaTableToGoValue(L, -1)
|
||||
L.SetTop(0)
|
||||
if m, ok := raw.(map[string]interface{}); ok {
|
||||
ui.Visibility = asString(m["visibility"])
|
||||
if scope, ok := m["state_scope"].(map[string]interface{}); ok {
|
||||
sc := &StateScope{}
|
||||
if gs, ok := scope["global"].([]interface{}); ok {
|
||||
for _, g := range gs {
|
||||
if s := asString(g); s != "" {
|
||||
sc.Global = append(sc.Global, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
if ds, ok := scope["depth"].(map[string]interface{}); ok {
|
||||
sc.Depth = map[string]int{}
|
||||
for k, v := range ds {
|
||||
if n, ok := asFloat(v); ok {
|
||||
sc.Depth[k] = int(n)
|
||||
}
|
||||
}
|
||||
sc.Depth = compileDepthMap(sc.Depth)
|
||||
}
|
||||
ui.StateScope = sc
|
||||
}
|
||||
if pg, ok := m["page"].(map[string]interface{}); ok {
|
||||
ui.Page = uipageFromValue(pg)
|
||||
}
|
||||
if pgs, ok := m["pages"].([]interface{}); ok {
|
||||
for _, it := range pgs {
|
||||
if pm, ok := it.(map[string]interface{}); ok {
|
||||
ui.Pages = append(ui.Pages, uipageFromValue(pm))
|
||||
}
|
||||
}
|
||||
}
|
||||
if els, ok := m["elements"].([]interface{}); ok {
|
||||
for _, it := range els {
|
||||
em, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
el := UIElement{
|
||||
Target: asString(em["target"]),
|
||||
Anchor: asString(em["anchor"]),
|
||||
Mount: asString(em["mount"]),
|
||||
Audience: asString(em["audience"]),
|
||||
}
|
||||
if n, ok := asFloat(em["order"]); ok {
|
||||
el.Order = int(n)
|
||||
}
|
||||
ui.Elements = append(ui.Elements, el)
|
||||
}
|
||||
m, ok := raw.(map[string]interface{})
|
||||
return m, ok
|
||||
}
|
||||
|
||||
// decodeUIExtension reads a plugin's `ui` declaration.
|
||||
//
|
||||
// The field list below is the single source of truth for what the gateway
|
||||
// understands. A declaration using anything else is REPORTED rather than
|
||||
// silently dropped, because the old hand-written reader ignored unknown keys with
|
||||
// no trace: `visibilty = "admin"` (one character off) restricted nothing and
|
||||
// looked exactly like a plugin that simply chose not to declare an audience.
|
||||
func decodeUIExtension(ctx *decodeCtx, m map[string]interface{}) UIExtension {
|
||||
ctx.strictKeys(m, "ui", "visibility", "state_scope", "page", "pages", "elements")
|
||||
|
||||
var ui UIExtension
|
||||
ui.Visibility = decString(m, "visibility")
|
||||
|
||||
if scope := decTable(m, "state_scope"); scope != nil {
|
||||
ctx.strictKeys(scope, "ui.state_scope", "global", "depth")
|
||||
sc := &StateScope{Global: decStringList(scope, "global")}
|
||||
if d := decIntMap(scope, "depth"); len(d) > 0 {
|
||||
sc.Depth = compileDepthMap(d)
|
||||
}
|
||||
ui.StateScope = sc
|
||||
}
|
||||
if ui.Page == nil && len(ui.Pages) == 0 && len(ui.Elements) == 0 {
|
||||
return nil, false
|
||||
|
||||
if pg := decTable(m, "page"); pg != nil {
|
||||
ui.Page = uipageFromValue(pg)
|
||||
}
|
||||
return &ui, true
|
||||
for _, pm := range decTableList(m, "pages") {
|
||||
ui.Pages = append(ui.Pages, uipageFromValue(pm))
|
||||
}
|
||||
for _, em := range decTableList(m, "elements") {
|
||||
ctx.strictKeys(em, "ui.elements[]", "target", "anchor", "mount", "audience", "order")
|
||||
el := UIElement{
|
||||
Target: decString(em, "target"),
|
||||
Anchor: decString(em, "anchor"),
|
||||
Mount: decString(em, "mount"),
|
||||
Audience: decString(em, "audience"),
|
||||
}
|
||||
if n, ok := asFloat(em["order"]); ok {
|
||||
el.Order = int(n)
|
||||
}
|
||||
ui.Elements = append(ui.Elements, el)
|
||||
}
|
||||
return ui
|
||||
}
|
||||
|
||||
func (ps *Plugins) append(p *Plugin) {
|
||||
@ -2176,64 +2170,82 @@ func containsPage(pages []*UIPage, id string) bool {
|
||||
// not exist. Failing the whole plugin would take a working billing widget down
|
||||
// over a typo in an experimental endpoint.
|
||||
func readPluginHTTP(L *golua.State) []HTTPHandler {
|
||||
L.GetGlobal(pluginGlobal)
|
||||
if L.IsNil(-1) {
|
||||
L.SetTop(0)
|
||||
return nil
|
||||
}
|
||||
L.GetField(-1, "http")
|
||||
if L.Type(-1) != golua.LUA_TTABLE {
|
||||
L.SetTop(0)
|
||||
return nil
|
||||
}
|
||||
// luaTableToGoValue, NOT luaToJSON and NOT a hand-rolled GetField loop.
|
||||
// `http` is a LIST of declarations, each naming the handler function by
|
||||
// STRING:
|
||||
//
|
||||
// luaToJSON needs the json global, which is not installed yet while a plugin
|
||||
// is registering. And golua's GetField does NOT convert a numeric key: with
|
||||
// a string index "1" it returns nil, so an index loop silently produced an
|
||||
// empty list and every plugin appeared to declare no endpoints at all.
|
||||
raw := luaTableToGoValue(L, -1)
|
||||
L.SetTop(0)
|
||||
items, ok := raw.([]interface{})
|
||||
// http = {
|
||||
// { path = "health", method = "GET", roles = { "admin" }, handler = "health" },
|
||||
// }
|
||||
//
|
||||
// handler is a name, not a value. An inline function silently produced an
|
||||
// empty handler field and the endpoint was dropped without a word.
|
||||
raw, ok := pluginFieldValue(L, "http")
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
// A single declaration may be given as a bare table instead of a one-element
|
||||
// list; accepting both is friendlier than demanding one spelling.
|
||||
items, isList := raw.([]interface{})
|
||||
if !isList {
|
||||
if one, isTable := raw.(map[string]interface{}); isTable {
|
||||
items = []interface{}{one}
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
ctx := &decodeCtx{plugin: "http"}
|
||||
var list []HTTPHandler
|
||||
for _, it := range items {
|
||||
m, ok := it.(map[string]interface{})
|
||||
h, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
h := HTTPHandler{
|
||||
Path: asString(m["path"]),
|
||||
Method: asString(m["method"]),
|
||||
Handler: asString(m["handler"]),
|
||||
Description: asString(m["description"]),
|
||||
Public: m["public"] == true,
|
||||
ctx.strictKeys(h, "http[]", "path", "method", "handler", "description",
|
||||
"roles", "keys", "public")
|
||||
dh := HTTPHandler{
|
||||
Path: decString(h, "path"),
|
||||
Method: decString(h, "method"),
|
||||
Description: decString(h, "description"),
|
||||
Roles: decStringList(h, "roles"),
|
||||
Keys: decStringList(h, "keys"),
|
||||
}
|
||||
if roles, ok := m["roles"].([]interface{}); ok {
|
||||
for _, r := range roles {
|
||||
if s := asString(r); s != "" {
|
||||
h.Roles = append(h.Roles, s)
|
||||
}
|
||||
// handler must be a NAME, not a value. asString would happily turn a
|
||||
// function into its address ("function: 0x..."), which then failed much
|
||||
// later at dispatch with no connection to the declaration that caused it.
|
||||
// Rejecting it here names the field and the path.
|
||||
if hv, present := h["handler"]; present {
|
||||
name, isStr := hv.(string)
|
||||
if !isStr {
|
||||
ctx.warnf("http[%s]: handler must be the NAME of a function on the "+
|
||||
"plugin table, not the function itself", decString(h, "path"))
|
||||
continue
|
||||
}
|
||||
dh.Handler = name
|
||||
}
|
||||
if keys, ok := m["keys"].([]interface{}); ok {
|
||||
for _, k := range keys {
|
||||
if s := asString(k); s != "" {
|
||||
h.Keys = append(h.Keys, s)
|
||||
}
|
||||
}
|
||||
if p, ok := h["public"].(bool); ok {
|
||||
dh.Public = p
|
||||
}
|
||||
// A malformed entry is dropped rather than fatal: one bad route must not
|
||||
// cost a plugin its hooks and pages. That is also why the mounted count
|
||||
// can be lower than the declared count — /api/plugins lists what mounted.
|
||||
if method, mErr := normalizeHandlerMethod(h.Method); mErr == nil {
|
||||
h.Method = method
|
||||
if validateHandlerPath(h.Path) == nil && h.Handler != "" {
|
||||
list = append(list, h)
|
||||
}
|
||||
// cost a plugin its hooks and pages. The reason is named so a plugin whose
|
||||
// endpoint silently vanished can find out why.
|
||||
method, mErr := normalizeHandlerMethod(dh.Method)
|
||||
switch {
|
||||
case mErr != nil:
|
||||
ctx.warnf("http[%s]: %v", dh.Path, mErr)
|
||||
continue
|
||||
case validateHandlerPath(dh.Path) != nil:
|
||||
ctx.warnf("http[%s]: invalid path", dh.Path)
|
||||
continue
|
||||
case dh.Handler == "":
|
||||
ctx.warnf("http[%s]: handler must name a function on the plugin table "+
|
||||
"(an inline function is not supported)", dh.Path)
|
||||
continue
|
||||
}
|
||||
dh.Method = method
|
||||
list = append(list, dh)
|
||||
}
|
||||
if len(ctx.problems) > 0 {
|
||||
log.Printf("[plugins] %s", strings.Join(ctx.problems, "; "))
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user