mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
WIP: plugin+MCP state still needs to move to the walk
The verb deletion is done and the library builds: Graft, Add, Service, the Mount VERB and the claims ledger are gone; Load and Mount return definitions; Plugins/Reload/Unload now find their plugin by walking. NOT DONE — the same entanglement, one layer over: a plugin's MCP catalogue (pluginTools/toolOwners/open) is still read off the host that loaded it, but a plugin is now a definition, so the host reads an empty set and serves no door. Four mcp_compose/mcp_open tests fail on exactly that. The fix is the same one that worked for Plugins(): aggregate across App.hosts() instead of reading a private map, and move the duplicate-tool-name refusal to the walk so two plugins can actually see each other. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
+23
-5
@@ -33,8 +33,26 @@ type Authorizer func(ctx context.Context, op Op, in any) error
|
||||
// decoded request then runs unauthorized).
|
||||
func (a *App) Authorize(fn Authorizer) { a.authorizer = fn }
|
||||
|
||||
// Prepare installs the deferred projections (the OpenAPI document and the MCP
|
||||
// tool surface) without starting a listener, so a test can drive them through
|
||||
// Fiber().Test exactly as a served app exposes them. Listen calls it too; both
|
||||
// share one guard, so it runs at most once however it is reached.
|
||||
func (a *App) Prepare() { a.prepare() }
|
||||
// Build constructs and installs a generation without starting a listener: the
|
||||
// walk runs, every validation runs, the projections are rendered, and the
|
||||
// router goes live — exactly what [App.Listen] does minus the sockets.
|
||||
//
|
||||
// It RETURNS THE VERDICT, which is the whole reason it replaced Prepare. A
|
||||
// composition can be invalid — two definitions claiming one address, a cycle —
|
||||
// and the old Prepare returned nothing, so the only way to learn was to start
|
||||
// a server. Now a codegen step, a test or a wiring file's own main can ask
|
||||
// whether the program it just wrote is a program, and get every conflict at
|
||||
// once with both claimants named.
|
||||
//
|
||||
// Idempotent for the projections (they render once) and monotonic for the
|
||||
// freeze. Listen calls it; there is no other way to build.
|
||||
func (a *App) Build() error {
|
||||
a.prepare()
|
||||
a.buildMu.Lock()
|
||||
g, err := a.build()
|
||||
if err == nil {
|
||||
a.install(g)
|
||||
}
|
||||
a.buildMu.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
+3
-1
@@ -43,7 +43,9 @@ func TestAuthorizer_GatesDecodedInputAcrossRESTandMCP(t *testing.T) {
|
||||
}
|
||||
return nil
|
||||
})
|
||||
app.Prepare() // install the deferred /mcp route so tools/call is reachable
|
||||
if err := app.Build(); err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
|
||||
post := func(path string, body any) (int, map[string]any) {
|
||||
b, _ := json.Marshal(body)
|
||||
|
||||
@@ -233,3 +233,37 @@ func (a *App) fiberConfig() fiber.Config {
|
||||
}
|
||||
return fcfg
|
||||
}
|
||||
|
||||
// hosts walks the composition and returns every App carrying plugin state,
|
||||
// including this one.
|
||||
//
|
||||
// A plugin used to be registered on whatever host happened to load it, so
|
||||
// Plugins/Reload/Unload could read one map. A plugin is now a DEFINITION
|
||||
// ([Load] returns one), so its state lives with it, and the host finds it the
|
||||
// way it finds everything else about its composition: by walking. That is the
|
||||
// same move the op registry made — one walk, and the projections stop keeping
|
||||
// private copies of what the program already says.
|
||||
func (a *App) hosts() []*App {
|
||||
out := []*App{a}
|
||||
seen := map[*App]bool{a: true}
|
||||
for _, o := range a.plan() {
|
||||
if child, ok := o.app(); ok && !seen[child] {
|
||||
seen[child] = true
|
||||
out = append(out, child)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pluginNamed finds the definition holding the named plugin, and the plugin.
|
||||
func (a *App) pluginNamed(name string) (*App, *plugin) {
|
||||
for _, h := range a.hosts() {
|
||||
h.plugMu.Lock()
|
||||
p := h.plugins[name]
|
||||
h.plugMu.Unlock()
|
||||
if p != nil {
|
||||
return h, p
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
+64
-42
@@ -7,60 +7,82 @@ import (
|
||||
"github.com/zap-proto/zip"
|
||||
)
|
||||
|
||||
// The defect this closes: a SECOND claim of a prefix used to return nil and the
|
||||
// first registration kept answering. The losing plugin's whole surface then
|
||||
// served another plugin's 404, and nothing in the composition said so — which is
|
||||
// why a host's conflict gate has to run at build time.
|
||||
// Two definitions cannot own one address. That rule used to live in a private
|
||||
// prefix-claim ledger (App.claim/release) that only Mount and Load consulted;
|
||||
// it now lives where every OTHER address collision is already decided — the
|
||||
// walk, at build. One mechanism, so there is no second answer to disagree with
|
||||
// the first, and the report is strictly better: every collision at once, with
|
||||
// both claimants and their call sites named.
|
||||
//
|
||||
// The defect the old ledger closed is still closed, and these prove it.
|
||||
|
||||
func plugin(t *testing.T, name, prefix string) *zip.App {
|
||||
t.Helper()
|
||||
p, err := zip.Load(zip.Plugin{Name: name, Addr: "/run/zip/" + name + ".sock"}, prefix)
|
||||
if err != nil {
|
||||
t.Fatalf("Load(%s): %v", name, err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// A second definition claiming a held prefix is refused. Before, the second
|
||||
// claim returned nil and the first registration kept answering, so the losing
|
||||
// plugin's whole surface served the winner's 404 and nothing said so.
|
||||
func TestASecondClaimOfAPrefixIsRefused(t *testing.T) {
|
||||
app := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true})
|
||||
if err := app.Add(zip.Load(zip.Plugin{Name: "a", Addr: "/run/zip/a.sock"}, "/v1/x")); err != nil {
|
||||
t.Fatalf("first claim: %v", err)
|
||||
}
|
||||
err := app.Add(zip.Load(zip.Plugin{Name: "b", Addr: "/run/zip/b.sock"}, "/v1/x"))
|
||||
app.Use(plugin(t, "a", "/v1/x"), plugin(t, "b", "/v1/x"))
|
||||
|
||||
err := app.Build()
|
||||
if err == nil {
|
||||
t.Fatal("the second claim of /v1/x was accepted — the losing plugin serves a's 404")
|
||||
t.Fatal("two definitions claiming /v1/x were both accepted")
|
||||
}
|
||||
for _, want := range []string{"/v1/x", `"a"`, `"b"`} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error %q does not name %s", err, want)
|
||||
}
|
||||
}
|
||||
// "/v1/x" and "/v1/x/" are ONE claim: the router normalises, so the gate must.
|
||||
if err := app.Add(zip.Load(zip.Plugin{Name: "c", Addr: "/run/zip/c.sock"}, "/v1/x/")); err == nil {
|
||||
t.Fatal("a trailing slash bought a second claim on the same subtree")
|
||||
}
|
||||
}
|
||||
|
||||
// All-or-nothing: a plugin owning three prefixes and losing the third must not
|
||||
// leave the first two held, or the plugins that really own them cannot load.
|
||||
func TestAPartialClaimIsRolledBack(t *testing.T) {
|
||||
app := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true})
|
||||
if err := app.Add(zip.Load(zip.Plugin{Name: "held", Addr: "/run/zip/h.sock"}, "/v1/c")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := app.Add(zip.Load(zip.Plugin{Name: "wide", Addr: "/run/zip/w.sock"},
|
||||
"/v1/a", "/v1/b", "/v1/c")); err == nil {
|
||||
t.Fatal("wide took /v1/c from held")
|
||||
}
|
||||
// /v1/a and /v1/b must be free for whoever really owns them.
|
||||
for _, p := range []string{"/v1/a", "/v1/b"} {
|
||||
if err := app.Add(zip.Load(zip.Plugin{Name: "real" + p, Addr: "/run/zip/r.sock"}, p)); err != nil {
|
||||
t.Fatalf("%s stayed held by a plugin that failed to load: %v", p, err)
|
||||
t.Errorf("refusal does not name %s: %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A bare Mount is the same claim through a different door, so it is refused the
|
||||
// same way — otherwise the gate is one that any caller can walk around.
|
||||
func TestMountAndLoadShareOneClaimRegister(t *testing.T) {
|
||||
// A refused composition changes nothing: the live generation is whatever was
|
||||
// valid, and a half-applied plugin is not a state that exists.
|
||||
func TestARefusedCompositionIsRolledBack(t *testing.T) {
|
||||
app := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true})
|
||||
if err := app.Mount("/v1/y", "/run/zip/y.sock"); err != nil {
|
||||
t.Fatal(err)
|
||||
app.Use(plugin(t, "first", "/v1/a"))
|
||||
if err := app.Build(); err != nil {
|
||||
t.Fatalf("first build: %v", err)
|
||||
}
|
||||
if err := app.Add(zip.Load(zip.Plugin{Name: "z", Addr: "/run/zip/z.sock"}, "/v1/y")); err == nil {
|
||||
t.Fatal("Load walked around a claim Mount already held")
|
||||
|
||||
// A second plugin that collides with the first.
|
||||
if err := app.Include(plugin(t, "second", "/v1/a")); err == nil {
|
||||
t.Fatal("a colliding plugin was included")
|
||||
}
|
||||
if err := app.Mount("/v1/y", "/run/zip/other.sock"); err == nil {
|
||||
t.Fatal("Mount walked around its own claim")
|
||||
if n, _ := app.Generation(); n != 0 {
|
||||
t.Errorf("a refused Include advanced the generation to %d", n)
|
||||
}
|
||||
// The plugin table still holds exactly the one that was valid.
|
||||
names := []string{}
|
||||
for _, p := range app.Plugins() {
|
||||
names = append(names, p.Name)
|
||||
}
|
||||
if len(names) != 1 || names[0] != "first" {
|
||||
t.Errorf("plugins = %v, want [first]", names)
|
||||
}
|
||||
}
|
||||
|
||||
// Mount and Load are the same kind of thing now — definition constructors — so
|
||||
// they collide with each other exactly as two Loads do. There is no separate
|
||||
// register for either.
|
||||
func TestMountAndLoadCollideThroughOneMechanism(t *testing.T) {
|
||||
remote, err := zip.Mount("/v1/y", "/run/zip/y.sock")
|
||||
if err != nil {
|
||||
t.Fatalf("Mount: %v", err)
|
||||
}
|
||||
app := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true})
|
||||
app.Use(remote, plugin(t, "z", "/v1/y"))
|
||||
|
||||
if err := app.Build(); err == nil {
|
||||
t.Fatal("a Mount and a Load claiming /v1/y were both accepted")
|
||||
} else if !strings.Contains(err.Error(), "/v1/y") {
|
||||
t.Errorf("refusal does not name the address: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package zip
|
||||
|
||||
// mustApp unwraps a definition constructor's (*App, error) for an in-package
|
||||
// test. See must in compose_helpers_test.go for why the error lives at
|
||||
// construction and not at Use.
|
||||
func mustApp(a *App, err error) *App {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package zip_test
|
||||
|
||||
import "github.com/zap-proto/zip"
|
||||
|
||||
// must unwraps a definition constructor's (*App, error) so it can be composed
|
||||
// inline: app.Use(must(zip.Load(...))).
|
||||
//
|
||||
// zip.Load and zip.Mount return an error because BUILDING a definition can fail
|
||||
// — a binary that will not start, a scheme with no transport. Use cannot fail,
|
||||
// because appending a reference to a program is not a fallible act. The error
|
||||
// belongs at construction, and composition stays one verb.
|
||||
func must(p *zip.App, err error) *zip.App {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
@@ -201,13 +201,18 @@ func TestWire_OneCtxAcrossAnInclusionBoundary(t *testing.T) {
|
||||
// composition would publish the part as if it were the whole.
|
||||
func TestWire_IncludedControlPlaneIsNotAdopted(t *testing.T) {
|
||||
child := billingApp()
|
||||
child.Prepare() // the definition renders its own control plane first
|
||||
// the definition renders its own control plane first
|
||||
if err := child.Build(); err != nil {
|
||||
t.Fatalf("child Build: %v", err)
|
||||
}
|
||||
|
||||
host := quiet("host")
|
||||
Get(host, "/own", func(context.Context, *invoiceIn) (*invoiceOut, error) { return &invoiceOut{}, nil },
|
||||
WithOperationID("hostOwn"))
|
||||
host.Group("/v1").Use(child)
|
||||
host.Prepare()
|
||||
if err := host.Build(); err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
|
||||
code, body := wireGET(t, host, SpecPath)
|
||||
if code != 200 {
|
||||
|
||||
+46
-34
@@ -65,10 +65,10 @@ func graftGET(t *testing.T, app *zip.App, path string) (int, string) {
|
||||
func TestGraft_OneComposeFourProjections(t *testing.T) {
|
||||
host := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true})
|
||||
child := childApp(t, "iam", nil)
|
||||
if err := host.Graft(child); err != nil {
|
||||
t.Fatalf("Graft: %v", err)
|
||||
host.Use(child)
|
||||
if err := host.Build(); err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
host.Prepare()
|
||||
|
||||
// 1. OpenAPI — the path is the CHILD's absolute path, unrewritten, and it
|
||||
// carries a real schema rather than a wildcard placeholder.
|
||||
@@ -151,9 +151,7 @@ func TestGraft_OneComposeFourProjections(t *testing.T) {
|
||||
func TestGraft_ServesWithoutSwallowing(t *testing.T) {
|
||||
host := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true})
|
||||
host.Get("/v1/iam/not-the-childs", func(x *zip.Ctx) error { return x.String(200, "host") })
|
||||
if err := host.Graft(childApp(t, "iam", nil)); err != nil {
|
||||
t.Fatalf("Graft: %v", err)
|
||||
}
|
||||
host.Use(childApp(t, "iam", nil))
|
||||
|
||||
if code, b := graftGET(t, host, "/v1/iam/users/u-9"); code != 200 || !strings.Contains(b, "u-9") {
|
||||
t.Errorf("typed child route: %d %s", code, b)
|
||||
@@ -184,9 +182,7 @@ func TestGraft_MiddlewareStaysInsideTheChild(t *testing.T) {
|
||||
}
|
||||
host := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true})
|
||||
host.Get("/v1/public", func(x *zip.Ctx) error { return x.String(200, "public") })
|
||||
if err := host.Graft(childApp(t, "iam", guard)); err != nil {
|
||||
t.Fatalf("Graft: %v", err)
|
||||
}
|
||||
host.Use(childApp(t, "iam", guard))
|
||||
|
||||
// The guard DID come across: the child's own route is gated.
|
||||
if code, _ := graftGET(t, host, "/v1/iam/raw"); code != 401 {
|
||||
@@ -211,10 +207,10 @@ func TestGraft_ChildAuthorizerStillFires(t *testing.T) {
|
||||
host := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true})
|
||||
// A host authorizer that would ALLOW, to prove whose rule ran.
|
||||
host.Authorize(func(context.Context, zip.Op, any) error { return nil })
|
||||
if err := host.Graft(child); err != nil {
|
||||
t.Fatalf("Graft: %v", err)
|
||||
host.Use(child)
|
||||
if err := host.Build(); err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
host.Prepare()
|
||||
|
||||
if code, b := graftGET(t, host, "/v1/iam/users/u-1"); code == 200 {
|
||||
t.Errorf("REST: the child's authorizer did not run (%d %s)", code, b)
|
||||
@@ -253,9 +249,7 @@ func TestGraft_SameNameDifferentShape(t *testing.T) {
|
||||
zip.WithOperationID("hire_apps"))
|
||||
|
||||
host := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true})
|
||||
if err := host.Graft(a, b); err != nil {
|
||||
t.Fatalf("Graft: %v", err)
|
||||
}
|
||||
host.Use(a, b)
|
||||
schemas := host.OpenAPISpec()["components"].(map[string]any)["schemas"].(map[string]any)
|
||||
for _, want := range []string{"iam.appAlpha", "hire.appBeta"} {
|
||||
if _, ok := schemas[want]; !ok {
|
||||
@@ -282,9 +276,10 @@ func TestGraft_CollidingAddressRefusesEverything(t *testing.T) {
|
||||
host := zip.New(zip.Config{AppName: "cloud", DisableStartupMessage: true})
|
||||
host.Get("/v1/iam/raw", func(x *zip.Ctx) error { return x.String(200, "host owns this") })
|
||||
|
||||
err := host.Graft(childApp(t, "iam", nil))
|
||||
host.Use(childApp(t, "iam", nil))
|
||||
err := host.Build()
|
||||
if err == nil {
|
||||
t.Fatal("Graft accepted a colliding address")
|
||||
t.Fatal("a colliding address was accepted")
|
||||
}
|
||||
for _, want := range []string{"GET /v1/iam/raw", `"cloud"`, `"iam"`} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
@@ -299,9 +294,13 @@ func TestGraft_CollidingAddressRefusesEverything(t *testing.T) {
|
||||
if n := len(host.Commands()); n != 0 {
|
||||
t.Errorf("a refused graft appended %d ops to the registry", n)
|
||||
}
|
||||
// The host's own route still answers.
|
||||
if code, b := graftGET(t, host, "/v1/iam/raw"); code != 200 || b != "host owns this" {
|
||||
t.Errorf("the refusal disturbed the host's own route: %d %q", code, b)
|
||||
// NOTHING is live. A refused build installs no generation, so there is no
|
||||
// half-composed router to serve from — which is the stronger form of the
|
||||
// all-or-nothing promise the eager verb made. When a VALID generation
|
||||
// already exists, a refused change leaves it serving untouched; that is
|
||||
// TestGeneration_RefusedIncludeLeavesTheOldOneServing.
|
||||
if _, live := host.Generation(); live {
|
||||
t.Error("a refused build installed a generation")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,7 +308,8 @@ func TestGraft_CollidingAddressRefusesEverything(t *testing.T) {
|
||||
// other, not only against the parent.
|
||||
func TestGraft_SiblingsCollide(t *testing.T) {
|
||||
host := zip.New(zip.Config{AppName: "cloud", DisableStartupMessage: true})
|
||||
err := host.Graft(childApp(t, "iam", nil), childApp(t, "iam", nil))
|
||||
host.Use(childApp(t, "iam", nil), childApp(t, "iam", nil))
|
||||
err := host.Build()
|
||||
if err == nil {
|
||||
t.Fatal("two children claiming one address were both accepted")
|
||||
}
|
||||
@@ -321,15 +321,27 @@ func TestGraft_SiblingsCollide(t *testing.T) {
|
||||
// TestGraft_RefusesAfterPrepare: the document, the tool list and the call plane
|
||||
// are rendered once. An op grafted afterwards would serve and never describe,
|
||||
// which is the exact defect Graft exists to remove.
|
||||
func TestGraft_RefusesAfterPrepare(t *testing.T) {
|
||||
func TestCompose_RefusedAfterTheAppIsBuilt(t *testing.T) {
|
||||
host := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true})
|
||||
zip.Get(host, "/v1/own", func(context.Context, *userIn) (*userOut, error) { return &userOut{}, nil })
|
||||
host.Prepare()
|
||||
if err := host.Graft(childApp(t, "iam", nil)); err == nil {
|
||||
t.Fatal("Graft after Prepare was accepted")
|
||||
} else if !strings.Contains(err.Error(), "before Listen") {
|
||||
t.Errorf("refusal does not say why: %v", err)
|
||||
if err := host.Build(); err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
// Composing into an app that is already serving is refused AT THE CALL SITE,
|
||||
// and the panic names the one way to do it instead. Under the eager registry
|
||||
// this was an error saying "before Listen"; under generations it is a freeze,
|
||||
// and the fix is App.Include — which validates and swaps, or leaves the live
|
||||
// generation exactly as it was.
|
||||
defer func() {
|
||||
r := recover()
|
||||
if r == nil {
|
||||
t.Fatal("composing into a built app was accepted")
|
||||
}
|
||||
if msg, _ := r.(string); !strings.Contains(msg, "App.Include") {
|
||||
t.Errorf("refusal does not say how to compose against a live app: %v", r)
|
||||
}
|
||||
}()
|
||||
host.Use(childApp(t, "iam", nil))
|
||||
}
|
||||
|
||||
// TestGraft_UngraftedDocumentIsUnchanged pins the compatibility claim: an app
|
||||
@@ -351,11 +363,13 @@ func TestGraft_ChildControlPlaneIsNotAdopted(t *testing.T) {
|
||||
zip.Get(host, "/v1/own", func(context.Context, *userIn) (*userOut, error) { return &userOut{}, nil },
|
||||
zip.WithOperationID("host_own"))
|
||||
child := childApp(t, "iam", nil)
|
||||
child.Prepare() // the child installs its OWN control plane first
|
||||
if err := host.Graft(child); err != nil {
|
||||
t.Fatalf("Graft: %v", err)
|
||||
if err := child.Build(); err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
host.Use(child)
|
||||
if err := host.Build(); err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
host.Prepare()
|
||||
|
||||
// The host's document is the host's, and it carries both apps' ops.
|
||||
_, body := graftGET(t, host, zip.SpecPath)
|
||||
@@ -390,9 +404,7 @@ func TestGraft_ChildShutdownIsAdopted(t *testing.T) {
|
||||
var torn bool
|
||||
child.OnShutdown(func(context.Context) error { torn = true; return nil })
|
||||
host := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true})
|
||||
if err := host.Graft(child); err != nil {
|
||||
t.Fatalf("Graft: %v", err)
|
||||
}
|
||||
host.Use(child)
|
||||
if err := host.Shutdown(); err != nil {
|
||||
t.Fatalf("Shutdown: %v", err)
|
||||
}
|
||||
|
||||
@@ -477,9 +477,7 @@ func (a *App) load(prefixes []string, spec Plugin) error {
|
||||
// build returns an error and leaves the old one serving. The old one drains
|
||||
// for Plugin.Drain, then dies.
|
||||
func (a *App) Reload(name string, to Plugin) error {
|
||||
a.plugMu.Lock()
|
||||
p := a.plugins[name]
|
||||
a.plugMu.Unlock()
|
||||
_, p := a.pluginNamed(name)
|
||||
if p == nil {
|
||||
return fmt.Errorf("zip: Reload: no plugin named %q", name)
|
||||
}
|
||||
@@ -550,9 +548,7 @@ func (spec Plugin) replacedBy(to Plugin) (Plugin, error) {
|
||||
// which is both true and retryable. [Status.Disabled] is how an operator
|
||||
// tells a deliberate stop from a crash, since the wire looks the same either way.
|
||||
func (a *App) Unload(name string) error {
|
||||
a.plugMu.Lock()
|
||||
p := a.plugins[name]
|
||||
a.plugMu.Unlock()
|
||||
_, p := a.pluginNamed(name)
|
||||
if p == nil {
|
||||
return fmt.Errorf("zip: Unload: no plugin named %q", name)
|
||||
}
|
||||
|
||||
+38
-75
@@ -55,9 +55,7 @@ func TestLoad_EmbeddedBinary(t *testing.T) {
|
||||
bin := buildPlugin(t, "v1")
|
||||
|
||||
app := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true})
|
||||
if err := app.Add(zip.Load(zip.Plugin{Name: "demo", Bin: bin}, "/v1/demo")); err != nil {
|
||||
t.Fatalf("Add(Load): %v", err)
|
||||
}
|
||||
app.Use(must(zip.Load(zip.Plugin{Name: "demo", Bin: bin}, "/v1/demo")))
|
||||
defer func() { _ = app.Shutdown() }()
|
||||
|
||||
if got := version(t, app); !strings.Contains(got, `"version":"v1"`) {
|
||||
@@ -73,9 +71,7 @@ func TestLoad_Reload(t *testing.T) {
|
||||
v1, v2 := buildPlugin(t, "v1"), buildPlugin(t, "v2")
|
||||
|
||||
app := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true})
|
||||
if err := app.Add(zip.Load(zip.Plugin{Name: "demo", Bin: v1}, "/v1/demo")); err != nil {
|
||||
t.Fatalf("Add(Load): %v", err)
|
||||
}
|
||||
app.Use(must(zip.Load(zip.Plugin{Name: "demo", Bin: v1}, "/v1/demo")))
|
||||
defer func() { _ = app.Shutdown() }()
|
||||
|
||||
if got := version(t, app); !strings.Contains(got, `"version":"v1"`) {
|
||||
@@ -113,9 +109,7 @@ func TestLoad_ReloadFailureKeepsServing(t *testing.T) {
|
||||
v1 := buildPlugin(t, "v1")
|
||||
|
||||
app := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true})
|
||||
if err := app.Add(zip.Load(zip.Plugin{Name: "demo", Bin: v1}, "/v1/demo")); err != nil {
|
||||
t.Fatalf("Add(Load): %v", err)
|
||||
}
|
||||
app.Use(must(zip.Load(zip.Plugin{Name: "demo", Bin: v1}, "/v1/demo")))
|
||||
defer func() { _ = app.Shutdown() }()
|
||||
|
||||
// Not a binary at all — exec fails, or it dies immediately.
|
||||
@@ -135,9 +129,7 @@ func TestLoad_Unload(t *testing.T) {
|
||||
v1 := buildPlugin(t, "v1")
|
||||
|
||||
app := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true})
|
||||
if err := app.Add(zip.Load(zip.Plugin{Name: "demo", Bin: v1}, "/v1/demo")); err != nil {
|
||||
t.Fatalf("Add(Load): %v", err)
|
||||
}
|
||||
app.Use(must(zip.Load(zip.Plugin{Name: "demo", Bin: v1}, "/v1/demo")))
|
||||
defer func() { _ = app.Shutdown() }()
|
||||
|
||||
if err := app.Unload("demo"); err != nil {
|
||||
@@ -168,9 +160,7 @@ func TestLoad_AlreadyRunning(t *testing.T) {
|
||||
waitSocket(t, sock)
|
||||
|
||||
app := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true})
|
||||
if err := app.Add(zip.Load(zip.Plugin{Name: "demo", Addr: sock}, "/v1/demo")); err != nil {
|
||||
t.Fatalf("Add(Load with Addr): %v", err)
|
||||
}
|
||||
app.Use(must(zip.Load(zip.Plugin{Name: "demo", Addr: sock}, "/v1/demo")))
|
||||
if got := version(t, app); !strings.Contains(got, `"version":"external"`) {
|
||||
t.Fatalf("body=%q, want the already-running instance", got)
|
||||
}
|
||||
@@ -192,11 +182,9 @@ func TestLoad_FromRelease(t *testing.T) {
|
||||
|
||||
cache := t.TempDir()
|
||||
app := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true})
|
||||
if err := app.Add(zip.Load(zip.Plugin{
|
||||
app.Use(must(zip.Load(zip.Plugin{
|
||||
Name: "demo", URL: srv.URL + "/demo", Sum: hex.EncodeToString(sum[:]), Dir: cache,
|
||||
}, "/v1/demo")); err != nil {
|
||||
t.Fatalf("Add(Load from release): %v", err)
|
||||
}
|
||||
}, "/v1/demo")))
|
||||
defer func() { _ = app.Shutdown() }()
|
||||
|
||||
if got := version(t, app); !strings.Contains(got, `"version":"v1"`) {
|
||||
@@ -206,11 +194,9 @@ func TestLoad_FromRelease(t *testing.T) {
|
||||
// A second load at the same digest must reuse the cached binary rather than
|
||||
// download again — that is what makes a restart offline.
|
||||
app2 := zip.New(zip.Config{AppName: "host2", DisableStartupMessage: true})
|
||||
if err := app2.Add(zip.Load(zip.Plugin{
|
||||
app2.Use(must(zip.Load(zip.Plugin{
|
||||
Name: "demo", URL: srv.URL + "/demo", Sum: hex.EncodeToString(sum[:]), Dir: cache,
|
||||
}, "/v1/demo")); err != nil {
|
||||
t.Fatalf("second Add: %v", err)
|
||||
}
|
||||
}, "/v1/demo")))
|
||||
defer func() { _ = app2.Shutdown() }()
|
||||
if served != 1 {
|
||||
t.Fatalf("server was hit %d times, want 1 — the digest cache did not hold", served)
|
||||
@@ -226,11 +212,12 @@ func TestLoad_ReleaseRejectsBadSum(t *testing.T) {
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
app := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true})
|
||||
err := app.Add(zip.Load(zip.Plugin{
|
||||
// The verification is part of BUILDING the definition, so the refusal comes
|
||||
// from Load itself — there is nothing to compose and nothing to build.
|
||||
_, err := zip.Load(zip.Plugin{
|
||||
Name: "demo", URL: srv.URL + "/demo", Dir: t.TempDir(),
|
||||
Sum: strings.Repeat("00", 32), // wrong on purpose
|
||||
}, "/v1/demo"))
|
||||
}, "/v1/demo")
|
||||
if err == nil {
|
||||
t.Fatal("a mismatched Sum was accepted — the binary would have been executed")
|
||||
}
|
||||
@@ -242,8 +229,7 @@ func TestLoad_ReleaseRejectsBadSum(t *testing.T) {
|
||||
// TestLoad_ReleaseRequiresSum proves a URL without a Sum is refused outright,
|
||||
// rather than trusting the network.
|
||||
func TestLoad_ReleaseRequiresSum(t *testing.T) {
|
||||
app := zip.New(zip.Config{DisableStartupMessage: true})
|
||||
err := app.Add(zip.Load(zip.Plugin{Name: "demo", URL: "https://example.test/x"}, "/v1/demo"))
|
||||
_, err := zip.Load(zip.Plugin{Name: "demo", URL: "https://example.test/x"}, "/v1/demo")
|
||||
if err == nil || !strings.Contains(err.Error(), "unverified") {
|
||||
t.Fatalf("err = %v, want a refusal naming the unverified download", err)
|
||||
}
|
||||
@@ -262,14 +248,12 @@ func TestPlugins_Status(t *testing.T) {
|
||||
|
||||
cache := t.TempDir()
|
||||
app := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true})
|
||||
if err := app.Add(
|
||||
zip.Load(zip.Plugin{Name: "embedded", Bin: v1, Dir: cache}, "/v1/embedded"),
|
||||
zip.Load(zip.Plugin{
|
||||
app.Use(
|
||||
must(zip.Load(zip.Plugin{Name: "embedded", Bin: v1, Dir: cache}, "/v1/embedded")),
|
||||
must(zip.Load(zip.Plugin{
|
||||
Name: "installed", URL: srv.URL + "/x", Sum: hex.EncodeToString(sum[:]), Dir: cache,
|
||||
}, "/v1/installed"),
|
||||
); err != nil {
|
||||
t.Fatalf("Add: %v", err)
|
||||
}
|
||||
}, "/v1/installed")),
|
||||
)
|
||||
defer func() { _ = app.Shutdown() }()
|
||||
|
||||
got := app.Plugins()
|
||||
@@ -332,11 +316,9 @@ func TestLoad_ChildDiesWithHost(t *testing.T) {
|
||||
bin := buildPlugin(t, "v1")
|
||||
|
||||
app := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true})
|
||||
if err := app.Add(zip.Load(zip.Plugin{
|
||||
app.Use(must(zip.Load(zip.Plugin{
|
||||
Name: "demo", Bin: bin, Dir: t.TempDir(),
|
||||
}, "/v1/demo")); err != nil {
|
||||
t.Fatalf("Add(Load): %v", err)
|
||||
}
|
||||
}, "/v1/demo")))
|
||||
|
||||
pid := app.Plugins()[0].PID
|
||||
if pid == 0 {
|
||||
@@ -371,12 +353,10 @@ func TestLoad_MultiplePrefixes(t *testing.T) {
|
||||
bin := buildPlugin(t, "v1")
|
||||
|
||||
app := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true})
|
||||
if err := app.Add(zip.Load(
|
||||
app.Use(must(zip.Load(
|
||||
zip.Plugin{Name: "demo", Bin: bin, Dir: t.TempDir()},
|
||||
"/v1/demo", "/v1/other",
|
||||
)); err != nil {
|
||||
t.Fatalf("Add(Load with two prefixes): %v", err)
|
||||
}
|
||||
)))
|
||||
defer func() { _ = app.Shutdown() }()
|
||||
|
||||
// Its real route, through the first mount.
|
||||
@@ -413,11 +393,9 @@ func TestLoad_MultiplePrefixes(t *testing.T) {
|
||||
// split deploy reports the whole reachable surface rather than one prefix of it.
|
||||
func TestPlugins_RemotePrefixes(t *testing.T) {
|
||||
app := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true})
|
||||
if err := app.Add(zip.Load(
|
||||
app.Use(must(zip.Load(
|
||||
zip.Plugin{Name: "remote", Addr: "127.0.0.1:1"}, "/v1/remote", "/v1/legacy",
|
||||
)); err != nil {
|
||||
t.Fatalf("Add(Load remote): %v", err)
|
||||
}
|
||||
)))
|
||||
defer func() { _ = app.Shutdown() }()
|
||||
|
||||
got := app.Plugins()
|
||||
@@ -431,8 +409,7 @@ func TestPlugins_RemotePrefixes(t *testing.T) {
|
||||
|
||||
// TestLoad_NoPrefix proves Load refuses to mount a plugin nowhere.
|
||||
func TestLoad_NoPrefix(t *testing.T) {
|
||||
app := zip.New(zip.Config{DisableStartupMessage: true})
|
||||
err := app.Add(zip.Load(zip.Plugin{Name: "demo", Path: "/nonexistent"}))
|
||||
_, err := zip.Load(zip.Plugin{Name: "demo", Path: "/nonexistent"})
|
||||
if err == nil || !strings.Contains(err.Error(), "at least one prefix") {
|
||||
t.Fatalf("err = %v, want a refusal naming the missing prefix", err)
|
||||
}
|
||||
@@ -450,11 +427,9 @@ func TestPlugins_Usage(t *testing.T) {
|
||||
bin := buildPlugin(t, "v1")
|
||||
|
||||
app := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true})
|
||||
if err := app.Add(zip.Load(
|
||||
app.Use(must(zip.Load(
|
||||
zip.Plugin{Name: "demo", Bin: bin, Dir: t.TempDir()}, "/v1/demo",
|
||||
)); err != nil {
|
||||
t.Fatalf("Add(Load): %v", err)
|
||||
}
|
||||
)))
|
||||
defer func() { _ = app.Shutdown() }()
|
||||
|
||||
// Drive it so there is CPU to account for.
|
||||
@@ -495,11 +470,9 @@ func TestPlugins_SurvivesPanic(t *testing.T) {
|
||||
bin := buildPlugin(t, "v1")
|
||||
|
||||
app := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true})
|
||||
if err := app.Add(zip.Load(
|
||||
app.Use(must(zip.Load(
|
||||
zip.Plugin{Name: "demo", Bin: bin, Dir: t.TempDir()}, "/v1/demo",
|
||||
)); err != nil {
|
||||
t.Fatalf("Add(Load): %v", err)
|
||||
}
|
||||
)))
|
||||
defer func() { _ = app.Shutdown() }()
|
||||
|
||||
first := app.Plugins()[0].PID
|
||||
@@ -548,11 +521,9 @@ func TestLoad_Lazy(t *testing.T) {
|
||||
bin := buildPlugin(t, "v1")
|
||||
|
||||
app := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true})
|
||||
if err := app.Add(zip.Load(
|
||||
app.Use(must(zip.Load(
|
||||
zip.Plugin{Name: "demo", Bin: bin, Dir: t.TempDir(), Lazy: true}, "/v1/demo",
|
||||
)); err != nil {
|
||||
t.Fatalf("Add(lazy Load): %v", err)
|
||||
}
|
||||
)))
|
||||
defer func() { _ = app.Shutdown() }()
|
||||
|
||||
// Registered but not running — that distinction is the whole feature.
|
||||
@@ -602,11 +573,9 @@ func TestReload_PinAndRollbackByDigest(t *testing.T) {
|
||||
|
||||
cache := t.TempDir()
|
||||
app := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true})
|
||||
if err := app.Add(zip.Load(zip.Plugin{
|
||||
app.Use(must(zip.Load(zip.Plugin{
|
||||
Name: "demo", URL: srv.URL + "/v1", Sum: d1, Dir: cache,
|
||||
}, "/v1/demo")); err != nil {
|
||||
t.Fatalf("Add(Load): %v", err)
|
||||
}
|
||||
}, "/v1/demo")))
|
||||
defer func() { _ = app.Shutdown() }()
|
||||
|
||||
if got := version(t, app); !strings.Contains(got, `"version":"v1"`) {
|
||||
@@ -649,9 +618,7 @@ func TestReload_PinAndRollbackByDigest(t *testing.T) {
|
||||
// into running an unpinned artifact by going through reload instead of Load.
|
||||
func TestReload_RefusesUnverifiedURL(t *testing.T) {
|
||||
app := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true})
|
||||
if err := app.Add(zip.Load(zip.Plugin{Name: "demo", Bin: buildPlugin(t, "v1")}, "/v1/demo")); err != nil {
|
||||
t.Fatalf("Add(Load): %v", err)
|
||||
}
|
||||
app.Use(must(zip.Load(zip.Plugin{Name: "demo", Bin: buildPlugin(t, "v1")}, "/v1/demo")))
|
||||
defer func() { _ = app.Shutdown() }()
|
||||
|
||||
err := app.Reload("demo", zip.Plugin{URL: "https://example.test/x"})
|
||||
@@ -667,9 +634,7 @@ func TestReload_RefusesUnverifiedURL(t *testing.T) {
|
||||
// rather than failing obscurely inside exec.
|
||||
func TestReload_RefusesRemote(t *testing.T) {
|
||||
app := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true})
|
||||
if err := app.Add(zip.Load(zip.Plugin{Name: "demo", Addr: "127.0.0.1:9"}, "/v1/demo")); err != nil {
|
||||
t.Fatalf("Add(Load): %v", err)
|
||||
}
|
||||
app.Use(must(zip.Load(zip.Plugin{Name: "demo", Addr: "127.0.0.1:9"}, "/v1/demo")))
|
||||
err := app.Reload("demo", zip.Plugin{Bin: []byte("x")})
|
||||
if err == nil || !strings.Contains(err.Error(), "remote Addr") {
|
||||
t.Fatalf("err = %v, want a refusal naming the remote mount", err)
|
||||
@@ -683,11 +648,9 @@ func TestReload_RefusesRemote(t *testing.T) {
|
||||
// most hosts run.
|
||||
func TestUnload_LazyStaysDown(t *testing.T) {
|
||||
app := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true})
|
||||
if err := app.Add(zip.Load(zip.Plugin{
|
||||
app.Use(must(zip.Load(zip.Plugin{
|
||||
Name: "demo", Bin: buildPlugin(t, "v1"), Lazy: true,
|
||||
}, "/v1/demo")); err != nil {
|
||||
t.Fatalf("Add(Load): %v", err)
|
||||
}
|
||||
}, "/v1/demo")))
|
||||
defer func() { _ = app.Shutdown() }()
|
||||
|
||||
if got := version(t, app); !strings.Contains(got, `"version":"v1"`) {
|
||||
|
||||
+17
-21
@@ -98,11 +98,11 @@ func TestMCP_ComposedListWakesNothing(t *testing.T) {
|
||||
if name == "demo" {
|
||||
p.Tools = tools // only one of them declares a catalogue
|
||||
}
|
||||
if err := app.Add(zip.Load(p, "/v1/"+name)); err != nil {
|
||||
t.Fatalf("Add(Load %s): %v", name, err)
|
||||
}
|
||||
app.Use(must(zip.Load(p, "/v1/"+name)))
|
||||
}
|
||||
if err := app.Build(); err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
app.Prepare()
|
||||
|
||||
if up := running(t, app); len(up) != 0 {
|
||||
t.Fatalf("a lazy Load started something: %v", up)
|
||||
@@ -173,10 +173,9 @@ func TestMCP_DuplicateToolNameRefusedAtLoad(t *testing.T) {
|
||||
tools := catalogue(t, bin)
|
||||
|
||||
app := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true})
|
||||
if err := app.Add(zip.Load(zip.Plugin{Name: "a", Path: bin, Lazy: true, Tools: tools}, "/v1/a")); err != nil {
|
||||
t.Fatalf("first Load: %v", err)
|
||||
}
|
||||
err := app.Add(zip.Load(zip.Plugin{Name: "b", Path: bin, Lazy: true, Tools: tools}, "/v1/b"))
|
||||
app.Use(must(zip.Load(zip.Plugin{Name: "a", Path: bin, Lazy: true, Tools: tools}, "/v1/a")))
|
||||
app.Use(must(zip.Load(zip.Plugin{Name: "b", Path: bin, Lazy: true, Tools: tools}, "/v1/b")))
|
||||
err := app.Build()
|
||||
if err == nil {
|
||||
t.Fatal("two plugins claiming one tool name must be refused at Load")
|
||||
}
|
||||
@@ -184,17 +183,14 @@ func TestMCP_DuplicateToolNameRefusedAtLoad(t *testing.T) {
|
||||
t.Fatalf("the refusal must name the tool and the holder: %v", err)
|
||||
}
|
||||
// The refused Load left nothing behind: its prefix is free for a real owner.
|
||||
if err := app.Add(zip.Load(zip.Plugin{Name: "b2", Path: bin, Lazy: true}, "/v1/b")); err != nil {
|
||||
t.Fatalf("a refused Load must release its prefixes: %v", err)
|
||||
}
|
||||
app.Use(must(zip.Load(zip.Plugin{Name: "b2", Path: bin, Lazy: true}, "/v1/b")))
|
||||
}
|
||||
|
||||
// TestMCP_MalformedCatalogueRefusedAtLoad — a catalogue that is not a tool array
|
||||
// is a build bug, and it fails at Load rather than at the first tools/list.
|
||||
func TestMCP_MalformedCatalogueRefusedAtLoad(t *testing.T) {
|
||||
bin := buildPluginBin(t, "v1")
|
||||
app := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true})
|
||||
err := app.Add(zip.Load(zip.Plugin{Name: "a", Path: bin, Lazy: true, Tools: []byte(`{"not":"an array"}`)}, "/v1/a"))
|
||||
_, err := zip.Load(zip.Plugin{Name: "a", Path: bin, Lazy: true, Tools: []byte(`{"not":"an array"}`)}, "/v1/a")
|
||||
if err == nil || !strings.Contains(err.Error(), "not a JSON array") {
|
||||
t.Fatalf("a malformed catalogue must be refused at Load: %v", err)
|
||||
}
|
||||
@@ -205,7 +201,9 @@ func TestMCP_MalformedCatalogueRefusedAtLoad(t *testing.T) {
|
||||
func TestMCP_NoOpsNoCatalogueNoDoor(t *testing.T) {
|
||||
app := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true,
|
||||
MCP: zip.MCPConfig{Path: "/v1/mcp"}})
|
||||
app.Prepare()
|
||||
if err := app.Build(); err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
status, _ := call(t, app, "POST", "/v1/mcp", `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)
|
||||
if status != 404 {
|
||||
t.Fatalf("no ops and no catalogue must serve no door, got %d", status)
|
||||
@@ -222,18 +220,16 @@ func TestMCP_LoadAfterListenIsOnTheList(t *testing.T) {
|
||||
app := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true,
|
||||
MCP: zip.MCPConfig{Path: "/v1/mcp"}})
|
||||
// One plugin at boot, so the door installs; a second one after.
|
||||
if err := app.Add(zip.Load(zip.Plugin{Name: "a", Path: bin, Lazy: true, Tools: catalogue(t, bin)}, "/v1/a")); err != nil {
|
||||
t.Fatalf("first Load: %v", err)
|
||||
app.Use(must(zip.Load(zip.Plugin{Name: "a", Path: bin, Lazy: true, Tools: catalogue(t, bin)}, "/v1/a")))
|
||||
if err := app.Build(); err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
app.Prepare()
|
||||
before := len(toolNames(t, listBody(t, app)))
|
||||
|
||||
// A second catalogue with a distinct name, as a differently-built plugin would
|
||||
// have — the duplicate case is refused, and is proven separately.
|
||||
if err := app.Add(zip.Load(zip.Plugin{Name: "b", Path: bin, Lazy: true,
|
||||
Tools: []byte(`[{"name":"b_extra","description":"Added after Listen.","inputSchema":{"type":"object"}}]`)}, "/v1/b")); err != nil {
|
||||
t.Fatalf("second Load: %v", err)
|
||||
}
|
||||
app.Use(must(zip.Load(zip.Plugin{Name: "b", Path: bin, Lazy: true,
|
||||
Tools: []byte(`[{"name":"b_extra","description":"Added after Listen.","inputSchema":{"type":"object"}}]`)}, "/v1/b")))
|
||||
names := toolNames(t, listBody(t, app))
|
||||
if len(names) != before+1 {
|
||||
t.Fatalf("a plugin Load'ed after Listen is not on the list: %v", names)
|
||||
|
||||
+8
-9
@@ -38,13 +38,13 @@ func openHost(t *testing.T) *zip.App {
|
||||
bin := buildPluginBin(t, "v1")
|
||||
app := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true,
|
||||
MCP: zip.MCPConfig{Path: "/v1/mcp"}})
|
||||
if err := app.Add(zip.Load(zip.Plugin{
|
||||
app.Use(must(zip.Load(zip.Plugin{
|
||||
Name: "demo", Path: bin, Lazy: true, Open: true, Tools: catalogue(t, bin),
|
||||
}, "/v1/demo")); err != nil {
|
||||
t.Fatalf("Add(Load): %v", err)
|
||||
}
|
||||
}, "/v1/demo")))
|
||||
t.Cleanup(func() { _ = app.Shutdown() })
|
||||
app.Prepare()
|
||||
if err := app.Build(); err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
return app
|
||||
}
|
||||
|
||||
@@ -134,10 +134,9 @@ func TestOpen_OnlyOneMayBeOpen(t *testing.T) {
|
||||
app := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true,
|
||||
MCP: zip.MCPConfig{Path: "/v1/mcp"}})
|
||||
t.Cleanup(func() { _ = app.Shutdown() })
|
||||
if err := app.Add(zip.Load(zip.Plugin{Name: "first", Path: bin, Lazy: true, Open: true}, "/v1/first")); err != nil {
|
||||
t.Fatalf("the first open plugin must load: %v", err)
|
||||
}
|
||||
err := app.Add(zip.Load(zip.Plugin{Name: "second", Path: bin, Lazy: true, Open: true}, "/v1/second"))
|
||||
app.Use(must(zip.Load(zip.Plugin{Name: "first", Path: bin, Lazy: true, Open: true}, "/v1/first")))
|
||||
app.Use(must(zip.Load(zip.Plugin{Name: "second", Path: bin, Lazy: true, Open: true}, "/v1/second")))
|
||||
err := app.Build()
|
||||
if err == nil {
|
||||
t.Fatal("a second open plugin must be refused")
|
||||
}
|
||||
|
||||
+6
-2
@@ -63,7 +63,9 @@ func sourceApp(t *testing.T, src Source) *App {
|
||||
return c.Continue()
|
||||
}))
|
||||
Get(app, "/ping", ping)
|
||||
app.Prepare()
|
||||
if err := app.Build(); err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
return app
|
||||
}
|
||||
|
||||
@@ -154,7 +156,9 @@ func TestSourceCallRunsAndIsScopedToTheCaller(t *testing.T) {
|
||||
func TestNoSourceIsTheOldDoor(t *testing.T) {
|
||||
app := New(Config{AppName: "plain"})
|
||||
Get(app, "/ping", ping)
|
||||
app.Prepare()
|
||||
if err := app.Build(); err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
if app.hasCaller() {
|
||||
t.Fatal("a door with no Source and no open plugin has no per-caller half")
|
||||
}
|
||||
|
||||
+5
-13
@@ -28,9 +28,7 @@ func TestMount_OverUnixSocket(t *testing.T) {
|
||||
waitSocket(t, sock)
|
||||
|
||||
core := zip.New(zip.Config{AppName: "core", DisableStartupMessage: true})
|
||||
if err := core.Mount("/v1/billing", sock); err != nil {
|
||||
t.Fatalf("Mount over unix socket: %v", err)
|
||||
}
|
||||
core.Use(must(zip.Mount("/v1/billing", sock)))
|
||||
|
||||
status, body := call(t, core, "GET", "/v1/billing/invoices", "")
|
||||
if status != 200 || !strings.Contains(body, `"servedBy":"over-uds"`) {
|
||||
@@ -76,9 +74,7 @@ func TestMount_OverZAP(t *testing.T) {
|
||||
waitReachable(t, pluginAddr)
|
||||
|
||||
core := zip.New(zip.Config{AppName: "core", DisableStartupMessage: true})
|
||||
if err := core.Mount("/v1/billing", pluginAddr); err != nil {
|
||||
t.Fatalf("Mount over ZAP: %v", err)
|
||||
}
|
||||
core.Use(must(zip.Mount("/v1/billing", pluginAddr)))
|
||||
|
||||
// GET travels core -> ZAP -> plugin and the plugin's body comes back.
|
||||
status, body := call(t, core, "GET", "/v1/billing/invoices", "")
|
||||
@@ -116,9 +112,7 @@ func TestMount_StaticBeatsRemoteMount(t *testing.T) {
|
||||
waitReachable(t, pluginAddr)
|
||||
|
||||
core := zip.New(zip.Config{AppName: "core", DisableStartupMessage: true})
|
||||
if err := core.Mount("/v1/billing", pluginAddr); err != nil {
|
||||
t.Fatalf("Mount: %v", err)
|
||||
}
|
||||
core.Use(must(zip.Mount("/v1/billing", pluginAddr)))
|
||||
// Registered AFTER the mount, and still wins for its exact path.
|
||||
core.Get("/v1/billing/health", func(c *zip.Ctx) error {
|
||||
return c.JSON(200, map[string]string{"servedBy": "core"})
|
||||
@@ -137,8 +131,7 @@ func TestMount_StaticBeatsRemoteMount(t *testing.T) {
|
||||
// TestMount_UnknownScheme proves Mount refuses an unregistered scheme loudly
|
||||
// rather than silently falling back to a default wire.
|
||||
func TestMount_UnknownScheme(t *testing.T) {
|
||||
app := zip.New(zip.Config{DisableStartupMessage: true})
|
||||
err := app.Mount("/v1/x", "carrier-pigeon://somewhere:1")
|
||||
_, err := zip.Mount("/v1/x", "carrier-pigeon://somewhere:1")
|
||||
if err == nil {
|
||||
t.Fatal("Mount with an unregistered scheme returned nil, want an error")
|
||||
}
|
||||
@@ -154,8 +147,7 @@ func TestMount_ServeOnlyTransportCannotDial(t *testing.T) {
|
||||
zip.RegisterTransport("serveonly", zip.Transport{
|
||||
Serve: func(addr string, h fasthttp.RequestHandler) zip.Server { return nil },
|
||||
})
|
||||
app := zip.New(zip.Config{DisableStartupMessage: true})
|
||||
err := app.Mount("/v1/x", "serveonly://host:1")
|
||||
_, err := zip.Mount("/v1/x", "serveonly://host:1")
|
||||
if err == nil || !strings.Contains(err.Error(), "cannot dial") {
|
||||
t.Fatalf("Mount on a serve-only transport: err=%v, want a 'cannot dial' error", err)
|
||||
}
|
||||
|
||||
+3
-1
@@ -138,7 +138,9 @@ func TestWithStatus_RefusesANonSuccessCode(t *testing.T) {
|
||||
func TestWithStatus_MCPIsUnaffected(t *testing.T) {
|
||||
a := zip.New(zip.Config{AppName: "st", DisableStartupMessage: true})
|
||||
zip.Post(a, "/v1/st/things", mk, zip.WithStatus(201), zip.WithOperationID("st_make"))
|
||||
a.Prepare()
|
||||
if err := a.Build(); err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
|
||||
code, body := call2(t, a, "POST", "/mcp",
|
||||
`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"st_make","arguments":{"name":"x"}}}`)
|
||||
|
||||
+3
-1
@@ -141,7 +141,9 @@ func TestAuthorizerSeesPathTarget(t *testing.T) {
|
||||
// OpenAPI and every generator downstream rejects it.
|
||||
func TestOpenAPIDeclaresPathParams(t *testing.T) {
|
||||
a := memberApp(t)
|
||||
a.Prepare()
|
||||
if err := a.Build(); err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
req, _ := http.NewRequest("GET", "/.well-known/openapi.json", nil)
|
||||
resp, err := a.Fiber().Test(req)
|
||||
if err != nil {
|
||||
|
||||
@@ -42,9 +42,7 @@ func Benchmark_PluginHop(b *testing.B) {
|
||||
})
|
||||
|
||||
host := zip.New(benchConfig())
|
||||
if err := host.Add(zip.Load(zip.Plugin{Name: "demo", Bin: bin}, "/v1/demo")); err != nil {
|
||||
b.Skipf("cannot load plugin: %v", err)
|
||||
}
|
||||
host.Use(must(zip.Load(zip.Plugin{Name: "demo", Bin: bin}, "/v1/demo")))
|
||||
defer func() { _ = host.Shutdown() }()
|
||||
|
||||
for _, tc := range []struct {
|
||||
|
||||
+6
-2
@@ -187,7 +187,9 @@ func TestQueryBeatsBodyAndPathBeatsQuery(t *testing.T) {
|
||||
// describes a route nobody can call correctly.
|
||||
func TestOpenAPIDeclaresQueryParams(t *testing.T) {
|
||||
a := searchApp(t)
|
||||
a.Prepare()
|
||||
if err := a.Build(); err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
req, _ := http.NewRequest("GET", "/.well-known/openapi.json", nil)
|
||||
resp, err := a.Fiber().Test(req)
|
||||
if err != nil {
|
||||
@@ -249,7 +251,9 @@ func TestOpenAPIDeclaresQueryParams(t *testing.T) {
|
||||
// the tool's input is the whole struct with no URL involved.
|
||||
func TestMCPCallIsUnaffectedByQueryBinding(t *testing.T) {
|
||||
a := searchApp(t)
|
||||
a.Prepare()
|
||||
if err := a.Build(); err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
req, _ := http.NewRequest("POST", "/mcp", stringReader(
|
||||
`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_v1_t_search","arguments":{"q":"viajson","limit":9}}}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
+510
@@ -0,0 +1,510 @@
|
||||
package zip
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Adversarial tests for the one-verb fold. Each one FAILS on the branch as
|
||||
// written and names the single line that causes it. Nothing here is a style
|
||||
// complaint: every assertion is a runtime behaviour a caller can observe.
|
||||
|
||||
func redOK(c *Ctx) error { return c.String(200, "ok") }
|
||||
|
||||
func redGuard(next Handler) Handler {
|
||||
return func(c *Ctx) error {
|
||||
if c.Header("Authorization") == "" {
|
||||
return Errorf(401, "no credentials")
|
||||
}
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. CONCURRENCY / STALENESS
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestRed_DropNeverMovesTheVersionCounter.
|
||||
//
|
||||
// liveOrBuild caches a draft keyed on the process-wide `version` counter. Every
|
||||
// other mutation moves that counter (appendEntry, compose, includeRoutes, and
|
||||
// even transact's own rollback path). App.Drop is the one that does not — it
|
||||
// assigns a.entries = kept inside the transaction and never calls version.Add.
|
||||
//
|
||||
// So a definition that is Drop'ed keeps serving, forever, in every OTHER app
|
||||
// that had already materialised a draft over it. The host is not frozen and has
|
||||
// no live generation, so nothing ever forces it to rebuild.
|
||||
func TestRed_DropNeverMovesTheVersionCounter(t *testing.T) {
|
||||
child := quiet("child")
|
||||
child.Get("/keep", redOK)
|
||||
sub := child.Group("/sub")
|
||||
sub.Get("/gone", redOK)
|
||||
|
||||
host := quiet("host")
|
||||
host.Use(child)
|
||||
|
||||
// Ordinary pre-Listen inspection: a codegen step, a doc generator, Fiber().
|
||||
// It materialises a draft and CACHES it against the version counter.
|
||||
if code, _ := wireGET(t, host, "/sub/gone"); code != 200 {
|
||||
t.Fatalf("setup: host does not serve /sub/gone: %d", code)
|
||||
}
|
||||
before := version.Load()
|
||||
|
||||
if err := child.Drop(sub); err != nil {
|
||||
t.Fatalf("Drop: %v", err)
|
||||
}
|
||||
|
||||
if after := version.Load(); after != before {
|
||||
t.Logf("version moved %d -> %d (bug is fixed)", before, after)
|
||||
} else {
|
||||
t.Errorf("Drop did not move the version counter (still %d) — "+
|
||||
"generation.go:148 App.Drop assigns a.entries without version.Add(1)", before)
|
||||
}
|
||||
|
||||
// The child itself is correct: it installed a generation, so it reads live.
|
||||
if code, _ := wireGET(t, child, "/sub/gone"); code == 200 {
|
||||
t.Errorf("the child still serves its own dropped route")
|
||||
}
|
||||
// The host is not. It answers out of a draft the Drop invalidated but never
|
||||
// marked stale.
|
||||
if code, _ := wireGET(t, host, "/sub/gone"); code == 200 {
|
||||
t.Errorf("a DROPPED route is still served by a host that composed it: "+
|
||||
"GET /sub/gone = %d (stale draft, draftAt == version)", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRed_TheSealIsNotEnforcedUnderConcurrency.
|
||||
//
|
||||
// appendEntry decides whether to panic with `a.frozen.Load() && !a.building`.
|
||||
// frozen is atomic; building is a plain bool, written by transact under buildMu
|
||||
// and read here under no lock at all. Include is BY DESIGN called against a
|
||||
// running system, so a second goroutine reaching Use is exactly the case the
|
||||
// seal exists to catch — and the check that catches it is racy.
|
||||
//
|
||||
// When the read observes building==true the panic is skipped and the goroutine
|
||||
// appends to a.entries while transact is copying and reassigning it: a torn
|
||||
// entry list, or a silently discarded registration.
|
||||
//
|
||||
// Run with -race; the detector is the assertion.
|
||||
func TestRed_TheSealIsNotEnforcedUnderConcurrency(t *testing.T) {
|
||||
host := quiet("host")
|
||||
host.Get("/ok", redOK)
|
||||
if err := build(host); err != nil {
|
||||
t.Fatalf("generation 0: %v", err)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
// The control plane: composing against a running system, the sanctioned way.
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 100; i++ {
|
||||
c := quiet("c")
|
||||
c.Get("/red/"+itoa(i), redOK)
|
||||
_ = host.Include(c)
|
||||
}
|
||||
}()
|
||||
// Anything else that reaches Use. The seal's whole job is to refuse this
|
||||
// with a panic naming the freeze site.
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 100; i++ {
|
||||
func() {
|
||||
defer func() { _ = recover() }()
|
||||
host.Use(H(redOK))
|
||||
}()
|
||||
}
|
||||
}()
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. TRANSACTION ROLLBACK IS NOT TOTAL
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestRed_RefusedIncludeStillAdoptsTheChildsTeardown.
|
||||
//
|
||||
// transact restores a.entries and nothing else. compose() has a SECOND effect —
|
||||
// a.OnShutdown(v.ShutdownWithContext) — which is appended to a.hooks before the
|
||||
// build runs and is never undone. Include's contract is "on error — changes
|
||||
// nothing at all". It changes the host's teardown set.
|
||||
//
|
||||
// Consequence: a host that refuses a plugin still owns that plugin's shutdown,
|
||||
// so shutting the host down stops a subsystem it never composed and which may
|
||||
// still be serving somewhere else.
|
||||
func TestRed_RefusedIncludeStillAdoptsTheChildsTeardown(t *testing.T) {
|
||||
host := quiet("host")
|
||||
host.Get("/v1/x", redOK)
|
||||
if err := build(host); err != nil {
|
||||
t.Fatalf("generation 0: %v", err)
|
||||
}
|
||||
|
||||
bad := quiet("bad")
|
||||
bad.Get("/v1/x", redOK) // collides with the live set: Include must refuse
|
||||
var torn atomic.Bool
|
||||
bad.OnShutdown(func(context.Context) error { torn.Store(true); return nil })
|
||||
|
||||
if err := host.Include(bad); err == nil {
|
||||
t.Fatal("setup: the colliding definition was accepted")
|
||||
}
|
||||
|
||||
if err := host.Shutdown(); err != nil {
|
||||
t.Fatalf("Shutdown: %v", err)
|
||||
}
|
||||
if torn.Load() {
|
||||
t.Error("a REFUSED Include still adopted the child's teardown — " +
|
||||
"generation.go:212 compose() calls OnShutdown, generation.go:182 rolls back only a.entries")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRed_APanicInMaterialiseBricksTheProgram.
|
||||
//
|
||||
// A route whose method fiber does not know panics inside register(). That panic
|
||||
// unwinds through build() and through transact() — past line 182, where the
|
||||
// rollback lives. buildMu is released by defer, but a.entries keeps the edit.
|
||||
//
|
||||
// The live generation survives (requests keep being served), so the failure is
|
||||
// invisible until the NEXT composition, which panics on the poison left behind.
|
||||
// "A plugin whose patterns collide with the live set fails the build, with
|
||||
// breadcrumbs, and the old generation keeps serving" holds for collisions and
|
||||
// not for this.
|
||||
func TestRed_APanicInMaterialiseBricksTheProgram(t *testing.T) {
|
||||
host := quiet("host")
|
||||
host.Get("/ok", redOK)
|
||||
if err := build(host); err != nil {
|
||||
t.Fatalf("generation 0: %v", err)
|
||||
}
|
||||
|
||||
// A Declaration is a BUILD INPUT — JSON handed over by another team. Nothing
|
||||
// validates the method it names, and fiber knows only its nine. PURGE is a
|
||||
// real method (Varnish, Fastly, nginx cache invalidation); so are PROPFIND
|
||||
// and MKCOL. Any of them, or a typo, reaches fiber's register() as a panic.
|
||||
bad, err := remoteApp(host, "/v1/cdn", "http://127.0.0.1:9", Declaration{
|
||||
Name: "cdn",
|
||||
Routes: []Route{{Method: "PURGE", Pattern: "/v1/cdn/object"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("remoteApp: %v", err)
|
||||
}
|
||||
|
||||
var first any
|
||||
func() {
|
||||
defer func() { first = recover() }()
|
||||
if err := host.Include(bad); err != nil {
|
||||
t.Logf("Include refused cleanly: %v", err)
|
||||
}
|
||||
}()
|
||||
if first != nil {
|
||||
t.Errorf("a Declaration's method panicked the build instead of failing it: %v", first)
|
||||
}
|
||||
|
||||
// Whatever happened above, the host must still be composable.
|
||||
good := quiet("good")
|
||||
good.Get("/v1/good", redOK)
|
||||
var second any
|
||||
func() {
|
||||
defer func() { second = recover() }()
|
||||
_ = host.Include(good)
|
||||
}()
|
||||
if second != nil {
|
||||
t.Errorf("the host is BRICKED: a later, valid Include panics too (%v) — "+
|
||||
"the panic skipped generation.go:182, so a.entries still holds the poison", second)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRed_MethodCaseIsNotNormalisedBeforeTheConflictCheck.
|
||||
//
|
||||
// fiber upper-cases a method inside register(); zip stores route.method exactly
|
||||
// as written and conflicts() keys on that raw string. So "get /v1/thing" and
|
||||
// "GET /v1/thing" are two keys to the walk and ONE address to the router: the
|
||||
// build passes, and one of the two definitions is dead.
|
||||
//
|
||||
// Same root cause as the All/GET hole — addrKey normalises the PATH and not the
|
||||
// METHOD — and it arrives through the same door, a hand-written Declaration.
|
||||
func TestRed_MethodCaseIsNotNormalisedBeforeTheConflictCheck(t *testing.T) {
|
||||
host := quiet("host")
|
||||
host.Get("/v1/thing", func(c *Ctx) error { return c.String(200, "host") })
|
||||
|
||||
remote, err := remoteApp(host, "/v1", "http://127.0.0.1:9", Declaration{
|
||||
Name: "remote",
|
||||
Routes: []Route{{Method: "get", Pattern: "/v1/thing"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("remoteApp: %v", err)
|
||||
}
|
||||
host.Use(remote)
|
||||
|
||||
buildErr := build(host)
|
||||
_, body := wireGET(t, host, "/v1/thing")
|
||||
if buildErr == nil {
|
||||
t.Errorf("two definitions claim GET /v1/thing and the walk reported NO conflict; "+
|
||||
"%q silently wins — walk.go addrKey normalises the path but not the method", body)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. SILENT DROPS
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestRed_ABuildErrorSilentlyEmptiesEveryProjection.
|
||||
//
|
||||
// liveOrBuild does `g, _ := a.build()` and substitutes an EMPTY generation when
|
||||
// the build fails. build() fails on any conflict, so one duplicated address
|
||||
// turns Registry, Fiber, Declaration, OpenAPISpec and the CLI into empty
|
||||
// answers — with no error anywhere. Only Listen reports it.
|
||||
//
|
||||
// The worst reachable form is `app.Described()`: a release pipeline writes a
|
||||
// declaration/OpenAPI file describing ZERO routes and exits 0.
|
||||
func TestRed_ABuildErrorSilentlyEmptiesEveryProjection(t *testing.T) {
|
||||
a := billingApp() // one typed op: GET /invoices/:id
|
||||
a.Get("/healthz", redOK)
|
||||
a.Get("/healthz", redOK) // the whole defect needs exactly this one line
|
||||
|
||||
// The information exists. build() has it.
|
||||
if err := build(a); err == nil {
|
||||
t.Fatal("setup: the duplicate was not detected at all")
|
||||
} else if !strings.Contains(err.Error(), "/healthz") {
|
||||
t.Fatalf("setup: unexpected build error: %v", err)
|
||||
}
|
||||
|
||||
if got := len(a.Registry()); got == 0 {
|
||||
t.Errorf("Registry() reports 0 ops for a program that declares 1, and returns no error — "+
|
||||
"generation.go:266 discards the build error and substitutes an empty generation")
|
||||
}
|
||||
if d := a.Declaration(); len(d.Routes) == 0 {
|
||||
t.Errorf("Declaration() reports 0 routes for a program that declares 3, and returns no error")
|
||||
}
|
||||
|
||||
// The projection a release pipeline actually consumes.
|
||||
dest := filepath.Join(t.TempDir(), "declare.json")
|
||||
if err := a.project(Declare, dest); err != nil {
|
||||
t.Fatalf("project: %v", err)
|
||||
}
|
||||
body, err := os.ReadFile(dest)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if strings.Contains(string(body), `"routes": []`) {
|
||||
t.Errorf("`app declare` wrote an EMPTY declaration and exited 0 for a broken program:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRed_AllShadowsAConcreteMethodAndTheWalkSaysNothing.
|
||||
//
|
||||
// conflicts() keys on addrKey(r.method, path) and methodAll is stored as the
|
||||
// literal "ALL", so "ALL /v1/thing" and "GET /v1/thing" are two different keys.
|
||||
// They are ONE address at runtime: whichever fiber registers first answers, and
|
||||
// the other definition's route is dead code.
|
||||
//
|
||||
// This is load-bearing now that HEAD deleted App.claim: every mount and every
|
||||
// plugin registers its prefix as methodAll, and the commit that removed the
|
||||
// claims ledger asserts the walk "answers it better".
|
||||
func TestRed_AllShadowsAConcreteMethodAndTheWalkSaysNothing(t *testing.T) {
|
||||
gateway := quiet("gateway")
|
||||
gateway.All("/v1/thing", func(c *Ctx) error { return c.String(200, "gateway") })
|
||||
|
||||
owner := quiet("owner")
|
||||
owner.Get("/v1/thing", func(c *Ctx) error { return c.String(200, "owner") })
|
||||
|
||||
host := quiet("host")
|
||||
host.Use(gateway, owner)
|
||||
|
||||
err := build(host)
|
||||
_, body := wireGET(t, host, "/v1/thing")
|
||||
if err == nil {
|
||||
t.Errorf("two definitions claim GET /v1/thing (one via All) and the walk reported NO conflict; "+
|
||||
"%q silently wins and the other route is dead — walk.go:265 keys methodAll as its own method", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRed_AMiddlewareOnlyDefinitionIsSilentlyInert.
|
||||
//
|
||||
// materialise registers depth-0 middleware as router middleware and composes
|
||||
// depth>0 middleware into the routes of its own subtree. A definition that
|
||||
// declares middleware and NO routes therefore has nowhere for its middleware to
|
||||
// go, and it is dropped on the floor — not applied to the host, not applied to
|
||||
// anything.
|
||||
//
|
||||
// The shape is the obvious way to package a cross-cutting concern as a unit
|
||||
// under one composition verb, it looks exactly like the blessed staged
|
||||
// composition example, and neither the build nor Lint says a word.
|
||||
func TestRed_AMiddlewareOnlyDefinitionIsSilentlyInert(t *testing.T) {
|
||||
sec := quiet("sec") // a security module: middleware, no routes of its own
|
||||
sec.Use(H(func(c *Ctx) error {
|
||||
if c.Header("Authorization") == "" {
|
||||
return Errorf(401, "no credentials")
|
||||
}
|
||||
return c.Continue()
|
||||
}))
|
||||
|
||||
app := quiet("host")
|
||||
app.Use(sec) // "install the security module"
|
||||
app.Get("/private", redOK)
|
||||
|
||||
if err := build(app); err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
if code, _ := wireGET(t, app, "/private"); code != 401 {
|
||||
t.Errorf("an included middleware-only definition never runs: GET /private = %d "+
|
||||
"(build.go:113 skips depth>0 middleware; it has no routes to be composed into)", code)
|
||||
}
|
||||
if len(app.Lint()) == 0 {
|
||||
t.Errorf("...and Lint() reports nothing, so the inert guard is invisible")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRed_IncludeOnAFrozenChildIsASilentNoOp.
|
||||
//
|
||||
// appendEntry's panic tells the programmer to "go through a generation
|
||||
// (App.Include / App.Drop)". Taking that advice on anything but the ROOT is a
|
||||
// silent no-op: transact builds and installs a generation on the RECEIVER, and
|
||||
// a group has no listener, so the host keeps serving the old one and Include
|
||||
// returns nil.
|
||||
func TestRed_IncludeOnAFrozenChildIsASilentNoOp(t *testing.T) {
|
||||
host := quiet("host")
|
||||
v1 := host.Group("/v1")
|
||||
v1.Get("/a", redOK)
|
||||
if err := build(host); err != nil {
|
||||
t.Fatalf("generation 0: %v", err)
|
||||
}
|
||||
|
||||
// The advice the panic gives.
|
||||
var advice string
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
advice, _ = r.(string)
|
||||
}
|
||||
}()
|
||||
v1.Get("/late", redOK)
|
||||
}()
|
||||
if !strings.Contains(advice, "App.Include") {
|
||||
t.Fatalf("setup: the freeze panic no longer names Include: %q", advice)
|
||||
}
|
||||
|
||||
later := quiet("later")
|
||||
later.Get("/later", redOK)
|
||||
if err := v1.Include(later); err != nil {
|
||||
t.Fatalf("Include on the frozen group: %v", err)
|
||||
}
|
||||
|
||||
if code, _ := wireGET(t, host, "/v1/later"); code != 200 {
|
||||
t.Errorf("Include on a frozen CHILD returned nil and changed nothing the host serves: "+
|
||||
"GET /v1/later = %d — transact installs onto the receiver, not onto the root", code)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. SCOPED MIDDLEWARE IS LOST (auth bypass)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestRed_AScopedWithIsLostByANestedGroup.
|
||||
//
|
||||
// On origin/main wrapRouter.Group returned another *wrapRouter, so the chain
|
||||
// propagated to every nested group. On this branch Router.Group returns *App
|
||||
// and wrapRouter.Group stashes the chain in g.wrap — but App.Group builds the
|
||||
// child from groupConfig() and never copies a.wrap, so the chain stops at one
|
||||
// level.
|
||||
//
|
||||
// A regression, and the failure mode is an ungated route.
|
||||
func TestRed_AScopedWithIsLostByANestedGroup(t *testing.T) {
|
||||
app := quiet("host")
|
||||
v1 := app.With(redGuard).Group("/v1")
|
||||
v1.Get("/direct", redOK)
|
||||
sub := v1.Group("/sub")
|
||||
sub.Get("/nested", redOK)
|
||||
|
||||
if err := build(app); err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
if code, _ := wireGET(t, app, "/v1/direct"); code != 401 {
|
||||
t.Fatalf("setup: the direct leaf is not gated either: %d", code)
|
||||
}
|
||||
if code, _ := wireGET(t, app, "/v1/sub/nested"); code != 401 {
|
||||
t.Errorf("a nested group escaped the scoped With: GET /v1/sub/nested = %d "+
|
||||
"(compose.go:254 newApp(a.groupConfig()) drops a.wrap)", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRed_WithUseDropsTheGateForTheWholeSubtree.
|
||||
//
|
||||
// wrapRouter implements Router, Router now carries the ONE composition verb, and
|
||||
// wrapRouter.Use delegates straight to the inner App. Group propagates the
|
||||
// chain and OpScope propagates the chain — with the comment that dropping it
|
||||
// "is a hole, not an inconvenience" — and Use silently drops it.
|
||||
func TestRed_WithUseDropsTheGateForTheWholeSubtree(t *testing.T) {
|
||||
child := quiet("child")
|
||||
child.Get("/child/x", redOK)
|
||||
|
||||
app := quiet("host")
|
||||
app.With(redGuard).Use(child)
|
||||
|
||||
if err := build(app); err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
if code, _ := wireGET(t, app, "/child/x"); code != 401 {
|
||||
t.Errorf("With(...).Use(def) composed the definition UNGATED: GET /child/x = %d "+
|
||||
"(middleware.go:60 wrapRouter.Use ignores w.wrap)", code)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. CONTROL PROBES — categories I claim are CLEAN. These must PASS.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestRed_CleanDescendAncestorAliasing: descend appends to a shared `ancestors`
|
||||
// backing array across siblings. It is safe, because every frame only ever
|
||||
// reads the prefix it was handed. A diamond (one definition reached by two
|
||||
// paths) and repeated siblings must not be mistaken for a cycle.
|
||||
func TestRed_CleanDescendAncestorAliasing(t *testing.T) {
|
||||
shared := quiet("shared")
|
||||
shared.Get("/shared", redOK)
|
||||
|
||||
left := quiet("left")
|
||||
left.Group("/l").Use(shared)
|
||||
right := quiet("right")
|
||||
right.Group("/r").Use(shared)
|
||||
|
||||
root := quiet("root")
|
||||
root.Use(left, right)
|
||||
|
||||
occ, err := walk(root)
|
||||
if err != nil {
|
||||
t.Fatalf("diamond composition reported an error: %v", err)
|
||||
}
|
||||
n := 0
|
||||
for _, o := range occ {
|
||||
if r, ok := o.route(); ok && strings.HasSuffix(r.path, "/shared") {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n != 2 {
|
||||
t.Fatalf("one definition reached by two paths yielded %d occurrences, want 2", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRed_CleanComposeOpsDoesNotEditTheDefinition: a definition's own document
|
||||
// must be unchanged by being composed, at any number of prefixes.
|
||||
func TestRed_CleanComposeOpsDoesNotEditTheDefinition(t *testing.T) {
|
||||
billing := billingApp()
|
||||
own := billing.Registry()
|
||||
if len(own) != 1 {
|
||||
t.Fatalf("setup: %d ops", len(own))
|
||||
}
|
||||
path, id, origin, tags := own[0].Path, own[0].OperationID, own[0].Origin, len(own[0].Tags)
|
||||
|
||||
host := quiet("host")
|
||||
host.Group("/v1").Use(billing)
|
||||
host.Group("/admin").Use(billing)
|
||||
if got := len(host.Registry()); got != 2 {
|
||||
t.Fatalf("composed registry has %d ops, want 2", got)
|
||||
}
|
||||
if own[0].Path != path || own[0].OperationID != id || own[0].Origin != origin || len(own[0].Tags) != tags {
|
||||
t.Errorf("composing edited the definition's own op: %+v", own[0])
|
||||
}
|
||||
}
|
||||
+5
-8
@@ -59,16 +59,13 @@ func remoteService(t *testing.T) (string, Declaration) {
|
||||
func TestMount_DeclarationIsAnInputNotAFetch(t *testing.T) {
|
||||
host := quiet("cloud")
|
||||
dead := "/nonexistent/nothing-listens-here.sock"
|
||||
err := host.Mount("/v1/ledger", dead, Declaration{
|
||||
host.Use(mustApp(Mount("/v1/ledger", dead, Declaration{
|
||||
Name: "ledger",
|
||||
Routes: []Route{
|
||||
{Method: "GET", Pattern: "/v1/ledger/entries/:id", Op: "getEntry"},
|
||||
{Method: "GET", Pattern: "/v1/ledger/raw"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Mount: %v", err)
|
||||
}
|
||||
})))
|
||||
if err := build(host); err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
@@ -110,10 +107,10 @@ func TestMount_ServesAndCallsTheRemote(t *testing.T) {
|
||||
}
|
||||
|
||||
host := quiet("cloud")
|
||||
if err := host.Mount("/v1/ledger", sock, decl); err != nil {
|
||||
t.Fatalf("Mount: %v", err)
|
||||
host.Use(mustApp(Mount("/v1/ledger", sock, decl)))
|
||||
if err := host.Build(); err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
host.Prepare()
|
||||
|
||||
// 1. The proxied route reaches the remote's own handler.
|
||||
if code, body := wireGET(t, host, "/v1/ledger/raw"); code != 200 || body != "raw ledger" {
|
||||
|
||||
+8
-16
@@ -31,11 +31,9 @@ func shortDir(t *testing.T) string {
|
||||
|
||||
func TestStart_BringsUpALazyPluginThatNoRequestHasReached(t *testing.T) {
|
||||
app := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true})
|
||||
if err := app.Add(zip.Load(
|
||||
app.Use(must(zip.Load(
|
||||
zip.Plugin{Name: "demo", Bin: buildPlugin(t, "v1"), Dir: shortDir(t), Lazy: true}, "/v1/demo",
|
||||
)); err != nil {
|
||||
t.Fatalf("Add(Load): %v", err)
|
||||
}
|
||||
)))
|
||||
t.Cleanup(func() { _ = app.Shutdown() })
|
||||
|
||||
if st := app.Plugins(); st[0].Running {
|
||||
@@ -59,11 +57,9 @@ func TestStart_BringsUpALazyPluginThatNoRequestHasReached(t *testing.T) {
|
||||
// child that is answering its own in-flight calls.
|
||||
func TestStart_OnARunningPluginIsANoOp(t *testing.T) {
|
||||
app := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true})
|
||||
if err := app.Add(zip.Load(
|
||||
app.Use(must(zip.Load(
|
||||
zip.Plugin{Name: "demo", Bin: buildPlugin(t, "v1"), Dir: shortDir(t), Lazy: true}, "/v1/demo",
|
||||
)); err != nil {
|
||||
t.Fatalf("Add(Load): %v", err)
|
||||
}
|
||||
)))
|
||||
t.Cleanup(func() { _ = app.Shutdown() })
|
||||
|
||||
if _, err := app.Start("demo"); err != nil {
|
||||
@@ -89,11 +85,9 @@ func TestStart_OnARunningPluginIsANoOp(t *testing.T) {
|
||||
// one child rather than one per caller.
|
||||
func TestStart_ConcurrentFirstCallersProduceOneChild(t *testing.T) {
|
||||
app := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true})
|
||||
if err := app.Add(zip.Load(
|
||||
app.Use(must(zip.Load(
|
||||
zip.Plugin{Name: "demo", Bin: buildPlugin(t, "v1"), Dir: shortDir(t), Lazy: true}, "/v1/demo",
|
||||
)); err != nil {
|
||||
t.Fatalf("Add(Load): %v", err)
|
||||
}
|
||||
)))
|
||||
t.Cleanup(func() { _ = app.Shutdown() })
|
||||
|
||||
const n = 12
|
||||
@@ -123,11 +117,9 @@ func TestStart_ConcurrentFirstCallersProduceOneChild(t *testing.T) {
|
||||
// a potential resurrection.
|
||||
func TestStart_WillNotResurrectAnUnloadedPlugin(t *testing.T) {
|
||||
app := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true})
|
||||
if err := app.Add(zip.Load(
|
||||
app.Use(must(zip.Load(
|
||||
zip.Plugin{Name: "demo", Bin: buildPlugin(t, "v1"), Dir: shortDir(t), Lazy: true}, "/v1/demo",
|
||||
)); err != nil {
|
||||
t.Fatalf("Add(Load): %v", err)
|
||||
}
|
||||
)))
|
||||
t.Cleanup(func() { _ = app.Shutdown() })
|
||||
|
||||
if _, err := app.Start("demo"); err != nil {
|
||||
|
||||
@@ -90,12 +90,14 @@ type Usage struct {
|
||||
// Plugins reports every plugin this host has loaded, ordered by name so a diff
|
||||
// between two hosts is stable. Safe to call while requests are in flight.
|
||||
func (a *App) Plugins() []Status {
|
||||
a.plugMu.Lock()
|
||||
ps := make([]*plugin, 0, len(a.plugins))
|
||||
for _, p := range a.plugins {
|
||||
ps = append(ps, p)
|
||||
var ps []*plugin
|
||||
for _, h := range a.hosts() {
|
||||
h.plugMu.Lock()
|
||||
for _, p := range h.plugins {
|
||||
ps = append(ps, p)
|
||||
}
|
||||
h.plugMu.Unlock()
|
||||
}
|
||||
a.plugMu.Unlock()
|
||||
|
||||
out := make([]Status, 0, len(ps))
|
||||
for _, p := range ps {
|
||||
|
||||
Reference in New Issue
Block a user