mount: one field says global, and the tmpfs goes with the dead target

Global was stated TWICE. cloud.Global(fn) returned a MountFunc that asserted
Router was *zip.App and, when it was not, returned an error telling you to also
set Global: true. Wrapper and flag were the same fact — exactly 6 and 6 — and
the wrapper could not work without the flag.

Now the field IS the grant: MountSpec.App takes func(*zip.App, Deps) error, set
instead of Mount. `{Name: "authz", Mount: cloud.Global(authz.Mount), Global:
true}` becomes `{Name: "authz", App: authz.Mount}`. MountAll refuses a spec with
both, so scoped-or-global stays a decision someone made in writing.

The adapter, its type assertion and its error message are deleted, and with them
two tests: mounting a plugin on a scoped Router and forgetting the flag are no
longer failure modes to check, they are unrepresentable. gen-app-cmds reads App
as it reads Mount — the expression naming the registrar — so the 108 standalone
mains regenerate unchanged in meaning.

Also gone: `make plugins` and the TMPDIR it forced. That target linked 100+
binaries back to back, which is what exhausted a tmpfs /tmp and what OOM'd a
128GiB box — so it built sequentially at -p=2. The multi-call binary replaced
the set it built: `ship` links two things, not 108. Monolith and plugin are not
two artifacts to pick between, they are one artifact under two invocations
(direct, or as a child with --enable), which is why there is nothing to keep in
sync. Measured numbers replace the stale ones: 4.41GB across 108 dedicated vs
196MB for the one, 23x.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
2026-07-27 22:28:15 -07:00
parent bd73e5cb95
commit 62c7f52d99
17 changed files with 134 additions and 164 deletions
+42 -44
View File
@@ -48,17 +48,10 @@ OPENAPI_DIR ?= ../openapi
CGO_ENABLED ?= 0
# Every app the light host mounts, read from the generated manifest — the same
# list cmd/host links, so `make plugins` cannot build a set that differs from the
# one the host expects to find beside it.
# list cmd/host links and the multi-call binary serves.
APPS := $(shell sed -n 's/.*{Name: "\([^"]*\)".*/\1/p' manifest/apps.go)
# Linking a cloud binary is a multi-GiB act, and the Go LINKER writes its
# temporaries to TMPDIR (not GOTMPDIR). On a host where /tmp is a tmpfs that is
# RAM, so `make plugins` — 100+ links back to back — exhausts it. Point it at
# disk by default; override for a box where /tmp is real.
export TMPDIR ?= $(HOME)/.cache/go-tmp
.PHONY: help native webui deploy-ui agentskills build host ship plugins plugin generate openapi run smoke test test-cgo test-codec vet tidy docker docker-push clean monolith monolith-standalone
.PHONY: help native webui deploy-ui agentskills build host ship plugin generate openapi run smoke test test-cgo test-codec vet tidy docker docker-push clean monolith monolith-standalone
help: ## Show this help.
@awk 'BEGIN{FS=":.*##";printf "\nUsage: make <target>\n\nTargets:\n"} /^[a-zA-Z_-]+:.*##/{printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
@@ -115,35 +108,22 @@ build: host ## FAST PATH (default): build the light host into ./bin/host. Then `
# The apps run as their own processes, started on the first request that reaches
# them, so the host's build does not grow when a subsystem does.
host: ## Build the light host into ./bin/host (links zip + the manifest, none of the apps).
@mkdir -p bin $(TMPDIR)
@mkdir -p bin
CGO_ENABLED=$(CGO_ENABLED) $(GO) build -ldflags="$(LDFLAGS)" -o bin/$@ ./cmd/$@
@echo ">> bin/host — $$(CGO_ENABLED=$(CGO_ENABLED) $(GO) list -deps ./cmd/host | wc -l) packages, $$(du -h bin/host | cut -f1)"
# Sequential and -p=2 on purpose: each link peaks in the GiBs, and building a
# hundred of them in parallel is how this OOMs a 128GiB box.
plugins: ## Build every app the manifest mounts into ./bin — the host's plugins. Slow by construction.
@mkdir -p bin $(TMPDIR)
@for a in $(APPS); do \
printf '>> %s\n' "$$a"; \
GOFLAGS=-p=2 CGO_ENABLED=$(CGO_ENABLED) $(GO) build -ldflags="$(LDFLAGS)" -o bin/$$a ./cmd/$$a || exit 1; \
done
@echo ">> $(words $(APPS)) plugins in ./bin"
# THE RELEASE LAYOUT, and the reason `plugins` above is a development target
# rather than a shipping one. A dedicated plugin is ~40MB of which ~35MB is the
# core every other plugin also links, so 108 of them are 4.5GB of duplicated
# THE RELEASE LAYOUT. A dedicated plugin is ~40MB of which ~35MB is the
# core every other plugin also links, so 108 of them measure 4.41GB of duplicated
# code (already stripped — -s -w is the default LDFLAGS, there is no symbol win
# left in it). The unified binary is that core ONCE and serves any app via
# `cloud --enable=<name>`, which manifest.App.Plugin falls through to when no
# dedicated binary sits beside the host. Same contract, same child, 20x less to
# ship.
# left in it). The unified binary is that core ONCE, 196MB, and serves any app
# via `cloud --enable=<name>`. Same contract, same child, 23x less to ship.
ship: host monolith ## Build the RELEASE layout into ./bin: the host + the one multi-call binary that serves all $(words $(APPS)) apps.
@echo ">> ship: host $$(du -h bin/host | cut -f1) + cloud $$(du -h bin/cloud | cut -f1) = $$(du -ch bin/host bin/cloud | tail -1 | cut -f1) for $(words $(APPS)) apps"
plugin: ## Build ONE app into ./bin: make plugin APP=wallets.
@test -n "$(APP)" || { echo "usage: make plugin APP=<name>"; echo "apps: $(APPS)"; exit 1; }
@test -d cmd/$(APP) || { echo "no cmd/$(APP) — run 'make generate', or check the name against 'make plugin' with no APP"; exit 1; }
@mkdir -p bin $(TMPDIR)
@mkdir -p bin
GOFLAGS=-p=2 CGO_ENABLED=$(CGO_ENABLED) $(GO) build -ldflags="$(LDFLAGS)" -o bin/$(APP) ./cmd/$(APP)
# apps.Wire() is the single source of truth for the subsystem set. This derives
@@ -192,6 +172,30 @@ TEST_TAGS := sqlite_fts5
test: ## Run unit + integration tests (pure-Go, with the FTS5 tag the image ships).
$(TEST_ENV) CGO_ENABLED=$(CGO_ENABLED) $(GO) test -tags "$(TEST_TAGS)" ./...
# THE spec, in one command. Three steps, in the only order they work in:
#
# 1. zipdoc lifts the doc comments off every typed handler into zipdoc_gen.go.
# Go drops comments at compile time, so this build-time pass is the ONLY way
# prose and examples reach the document. `-run zipdoc` picks the directives
# out of ./... by name, so a typed op added anywhere is covered and no
# unrelated generator fires.
# 2. the golden test mounts apps.Wire() and folds the two readings of that one
# router — the live route table (every operation) over zip's typed-op
# registry (schemas, parameters, responses, prose) — into one document.
# 3. it writes both sinks from that single value: openapi.yaml here, and the
# drop hanzoai/openapi aggregates, audits and generates SDKs from.
#
# openapi.yaml is a golden file: written with -update, VERIFIED by the same test
# with no flag, which `make test` (and therefore CI) already runs. That is the
# whole drift guard — change a route without regenerating and the build goes red
# before a stale spec reaches an SDK. OPENAPI_DIR is optional; without a checkout
# there the golden is still regenerated and guarded.
openapi: ## Regenerate the spec from the live router + typed registry, into openapi.yaml and $(OPENAPI_DIR)/generated.
$(GO) generate -run zipdoc ./...
$(TEST_ENV) CGO_ENABLED=$(CGO_ENABLED) $(GO) test -tags "$(TEST_TAGS)" -count=1 -run TestOpenAPIYAML ./cmd/cloud \
-update $(if $(wildcard $(OPENAPI_DIR)/capabilities.yaml),-publish="$(abspath $(OPENAPI_DIR))")
@echo ">> openapi.yaml — $$(grep -c '^ /' openapi.yaml) paths"
test-cgo: ## Prove the cgo build works too — forces the fork's pure-Go backend via -tags sqlite_purego so the embedded modernc importers don't double-register "sqlite".
$(TEST_ENV) CGO_ENABLED=1 $(GO) test -tags "sqlite_purego $(TEST_TAGS)" ./...
@@ -231,22 +235,16 @@ native: ## Build the native flags evaluator staticlib (required for CGO=1 builds
cargo build --release --manifest-path native/flags/Cargo.toml
# ---------------------------------------------------------------------------
# SLOW FALLBACK — everything linked together. Deliberately last, in the file and
# in `make help`, because typing it is a choice and it should look like one.
# The ONE binary. Running it directly serves every app in this process; the host
# running it as a child with --enable=<app> serves one. Same bits, same Serve,
# same middleware — "monolith" and "plugin" are not two artifacts to choose
# between, they are one artifact under two invocations, which is why there is
# nothing here to keep in sync.
#
# Two reasons it still exists:
# 1. It is the SHIPPED artifact today. The Dockerfile builds ./cmd/cloud, and
# the host+plugins layout cannot replace it until a plugin stops linking the
# whole core: the 106 plugins weigh 5.3GB in ./bin against this one binary's
# 212MB, because each statically re-links the same ~650-package root. The
# image gets ~25x bigger before the model pays off, so flipping the
# Dockerfile waits on cutting that floor, not on this target.
# 2. It is the reference the plugin set is checked against: every app mounted
# here through apps.Wire() is the same app cmd/<name> serves standalone, and
# when the two disagree this is the one that is right.
# Delete it when neither is true — not before.
monolith: ## SLOW FALLBACK: link every subsystem into one ./bin/cloud (3108 packages, 212MB, 9.5s warm / minutes cold). Prefer `make build`.
@mkdir -p bin $(TMPDIR)
# It is also what `ship` publishes and what manifest.App resolves to, on disk or
# over the network, when no dedicated binary sits beside the host.
monolith: ## Link the one multi-call binary into ./bin/cloud (3027 packages, 196MB). `ship` builds this plus the host.
@mkdir -p bin
CGO_ENABLED=$(CGO_ENABLED) $(GO) build -ldflags="$(LDFLAGS)" -o bin/$(BIN) $(PKG)
monolith-standalone: webui monolith ## SLOW FALLBACK: the REAL 1-binary console — console build:embed → webui/dist → monolith.
monolith-standalone: webui monolith ## The 1-binary console — console build:embed → webui/dist → monolith.
+6 -6
View File
@@ -220,7 +220,7 @@ func Wire() []cloud.MountSpec {
// hanzoai/metrics — native o11y. It declares its OWN narrow metrics.Deps (no
// hanzoai/cloud import), so Typed cannot adapt it; cloud.MountMetrics builds that
// Deps from cloud.Deps and calls metrics.Mount explicitly.
{Name: "metrics", Mount: cloud.Global(cloud.MountMetrics), Global: true},
{Name: "metrics", App: cloud.MountMetrics},
// Embedded runtime edge (/v1/ingress/*). STAGED — edge listeners stay off unless
// the operator names "ingress" in CLOUD_ENABLE.
{Name: "ingress", Mount: ingress.Mount, Shutdown: ingress.Shutdown},
@@ -260,12 +260,12 @@ func Wire() []cloud.MountSpec {
// mounted on the host by this line and 404s until zip.Plugin can name more than
// one prefix. Do not merge this to main before that is closed.
cloud.PluginSpec("o11y", where("o11y"), "/v1/o11y"),
{Name: "authz", Mount: cloud.Global(authz.Mount), Global: true},
{Name: "authz", App: authz.Mount},
// Embedded commerce plane /v1/commerce/*, /_/commerce/* — the hanzoai/commerce
// MODULE via the adapter in commerce.go (un-forked; the in-process
// CommerceClient is wired directly in pickCommerceClient).
{Name: "commerce", Mount: cloud.Global(commerce.Mount), Prefixes: commerce.Prefixes, Global: true},
{Name: "licensing", Mount: cloud.Global(licensing.Mount), Global: true},
{Name: "commerce", App: commerce.Mount, Prefixes: commerce.Prefixes},
{Name: "licensing", App: licensing.Mount},
// clients/plan.Mount. Enable id normalized "plans" -> "plan" to match the
// package + generated cmd/plan (one subsystem, one name). Its product routes
// stay /v1/plans/* (incl. the OwnsHealth /v1/plans/health probe) — unchanged.
@@ -524,7 +524,7 @@ func Wire() []cloud.MountSpec {
// catch-all so /v1/chat resolves here (Fiber first-match); the ai module's
// beego /v1/chat alias behind its /v1/* glob is thereby shadowed, while ai
// keeps /v1/chat/completions + /v1/completions.
{Name: "agent", Mount: cloud.Global(agent.Mount), Global: true},
{Name: "agent", App: agent.Mount},
// The UNIFIED GROUNDED ADVISOR — POST /v1/ask. DISTINCT from /v1/chat/completions
// (ai's RAW model) and /v1/agent (tool-calling): it routes a plain-language question
// to the domain(s) that can GROUND it, reads the REAL figures from each domain's own
@@ -550,7 +550,7 @@ func Wire() []cloud.MountSpec {
// other model and the /v1/models list. Order is load-bearing — Claim must
// run before ai's catch-all. (See hip-00NN.)
{Name: "zen", Mount: zen.Mount, Prefixes: []string{"/v1"}},
{Name: "ai", Mount: cloud.Global(ai.Mount), Prefixes: []string{"/v1"}, Global: true},
{Name: "ai", App: ai.Mount, Prefixes: []string{"/v1"}},
// Runtime wasm/proxy plugins — mounts dead last.
{Name: "plugins", Mount: plugin.Mount},
}
+8 -8
View File
@@ -22,7 +22,7 @@ var frozen = []struct {
name string
ownsHealth bool
hasShutdown bool
global bool // receives the bare *zip.App — see MountSpec.Global
global bool // receives the bare *zip.App — see MountSpec.App
}{
{"pubsub", false, true, false}, // was order 5
{"kafka", false, true, false}, // was order 6
@@ -39,7 +39,7 @@ var frozen = []struct {
// longer owns any o11y resource to close. The collector/sink/Datastore moved into
// the child, which flushes them in its OWN app.OnShutdown, and zip.Load registers
// the host-side hook that stops the child. A Shutdown on this spec would now be a
// host closing something it does not have. Name/OwnsHealth/Global are UNCHANGED —
// host closing something it does not have. Name/OwnsHealth/App are UNCHANGED —
// position, health routing and the app-wide grant are all still pinned here.
{"o11y", false, false, true}, // ONE observability subsystem (was co-owned orders 69+70), now out-of-process. OwnsHealth=false keeps /v1/o11y/health the generic always-ok route, which Serve registers before MountAll and therefore ahead of the plugin's /v1/o11y/* mount.
{"authz", false, false, true}, // was order 70
@@ -160,14 +160,14 @@ func TestWireOrderMatchesFrozen(t *testing.T) {
if (s.Shutdown != nil) != w.hasShutdown {
t.Errorf("position %d (%s): hasShutdown = %v, frozen = %v", i, s.Name, s.Shutdown != nil, w.hasShutdown)
}
// Global hands a subsystem the bare *zip.App, and with it the ability to
// gate every route in the binary. Freezing it here means a new grant cannot
// App hands a subsystem the bare *zip.App, and with it the ability to gate
// every route in the binary. Freezing it here means a new grant cannot
// arrive as a quiet field on one line of a 128-entry literal.
if s.Global != w.global {
t.Errorf("position %d (%s): Global = %v, frozen = %v — an app-wide capability changed", i, s.Name, s.Global, w.global)
if (s.App != nil) != w.global {
t.Errorf("position %d (%s): App = %v, frozen = %v — an app-wide capability changed", i, s.Name, s.App != nil, w.global)
}
if s.Mount == nil {
t.Errorf("position %d (%s): Mount is nil", i, s.Name)
if (s.Mount == nil) == (s.App == nil) {
t.Errorf("position %d (%s): needs exactly one of Mount or App", i, s.Name)
}
}
}
+18 -15
View File
@@ -978,7 +978,7 @@ func pickVaultClient(cfg *Config, log luxlog.Logger) VaultClient {
// property: middleware a subsystem installs lands on the subtrees its MountSpec
// declares, never over the binary. Routes register exactly as before — absolute
// paths, same precedence. See scope.go. A subsystem that genuinely gates
// everything says so with Global: true and gets the bare app.
// everything sets App instead and gets the bare app.
type MountFunc func(app Router, deps Deps) error
// ShutdownFunc releases a subsystem's process-lifetime resources (background
@@ -1038,18 +1038,16 @@ type MountSpec struct {
// routes still register at absolute paths anywhere, as they always have.
Prefixes []string
// Global says this subsystem gates the whole binary and receives the bare
// *zip.App. It is the one way to reach app-wide middleware, so every grant is a
// decision someone made in writing, and apps.TestWireFrozen fails on a new one.
// Today it is held only by linked modules whose own Mount still takes *zip.App
// (see cloud.Global) — none of which installs middleware.
Global bool
// App mounts against the whole binary instead of a scope, for a subsystem
// that gates everything. Set App or Mount, never both — the field IS the
// grant, so it is stated once and apps.TestWireFrozen fails on a new one.
App func(*zip.App, Deps) error
}
// MountAll mounts every ENABLED subsystem in specs, in slice order — the order is
// the composition root's (apps.Wire()); MountAll does NOT sort.
//
// app is the concrete *zip.App from Serve. A Global spec receives it. Everyone else
// app is the concrete *zip.App from Serve. An App spec receives it. Everyone else
// receives a scope bound to their declared Prefixes, so a subsystem's middleware
// reaches its own subtrees and nothing else, whatever its slice position. A
// subsystem that installs middleware outside them fails the mount — the binary
@@ -1069,14 +1067,19 @@ func MountAll(app *zip.App, specs []MountSpec, cfg *Config, deps Deps) error {
logger.Debug("subsystem disabled", "name", spec.Name)
continue
}
var router Router = app
var sc *scope
if !spec.Global {
sc = newScope(app, spec.Name, spec.Prefixes)
router = sc
if spec.App != nil && spec.Mount != nil {
return fmt.Errorf("mount %s: has both App and Mount — a subsystem is scoped or global, not both", spec.Name)
}
if err := spec.Mount(router, deps); err != nil {
return fmt.Errorf("mount %s: %w", spec.Name, err)
var sc *scope
if spec.App != nil {
if err := spec.App(app, deps); err != nil {
return fmt.Errorf("mount %s: %w", spec.Name, err)
}
} else {
sc = newScope(app, spec.Name, spec.Prefixes)
if err := spec.Mount(sc, deps); err != nil {
return fmt.Errorf("mount %s: %w", spec.Name, err)
}
}
if sc != nil {
if err := sc.err(); err != nil {
+2 -3
View File
@@ -18,9 +18,8 @@ import (
// link. The same app still mounts into the unified cloud binary via apps.Wire().
func main() {
if err := cloud.Serve([]cloud.MountSpec{{
Name: "agent",
Mount: cloud.Global(agent.Mount),
Global: true,
Name: "agent",
App: agent.Mount,
}}, []string{"agent"}); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
+1 -2
View File
@@ -19,9 +19,8 @@ import (
func main() {
if err := cloud.Serve([]cloud.MountSpec{{
Name: "ai",
Mount: cloud.Global(ai.Mount),
App: ai.Mount,
Prefixes: []string{"/v1"},
Global: true,
}}, []string{"ai"}); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
+2 -3
View File
@@ -18,9 +18,8 @@ import (
// link. The same app still mounts into the unified cloud binary via apps.Wire().
func main() {
if err := cloud.Serve([]cloud.MountSpec{{
Name: "authz",
Mount: cloud.Global(authz.Mount),
Global: true,
Name: "authz",
App: authz.Mount,
}}, []string{"authz"}); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
+1 -2
View File
@@ -19,9 +19,8 @@ import (
func main() {
if err := cloud.Serve([]cloud.MountSpec{{
Name: "commerce",
Mount: cloud.Global(commerce.Mount),
App: commerce.Mount,
Prefixes: commerce.Prefixes,
Global: true,
}}, []string{"commerce"}); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
+4 -2
View File
@@ -110,7 +110,7 @@ type spec struct {
// root's own statement of the subtrees this subsystem owns. The manifest reads
// it; the generated main copies its text like any other field.
prefixes ast.Expr
// mount is the Mount field's expression. The manifest reads the FUNCTION out
// mount is the Mount or App field's expression. The manifest reads the FUNCTION out
// of it, because two Wire entries can share one package (account) and only
// the function distinguishes what each of them serves.
mount ast.Expr
@@ -187,7 +187,9 @@ func capture(fset *token.FileSet, paths map[string]string, cl *ast.CompositeLit)
if key.Name == "Prefixes" {
s.prefixes = kv.Value
}
if key.Name == "Mount" {
// Mount (scoped) and App (whole-binary) are the same fact for this
// generator: the expression naming the registrar.
if key.Name == "Mount" || key.Name == "App" {
s.mount = kv.Value
}
text, err := source(fset, kv.Value)
+2 -2
View File
@@ -202,8 +202,8 @@ func entries(root string, s spec) []entry {
return true
}
for p := range s.imports {
// The qualifier's own import; cloud.Global/cloud.CtxShutdown are
// adapters, never the subsystem's registrar.
// The qualifier's own import; cloud.CtxShutdown is an
// adapter, never the subsystem's registrar.
if p == modPath || path.Base(p) != id.Name {
continue
}
+2 -3
View File
@@ -18,9 +18,8 @@ import (
// link. The same app still mounts into the unified cloud binary via apps.Wire().
func main() {
if err := cloud.Serve([]cloud.MountSpec{{
Name: "licensing",
Mount: cloud.Global(licensing.Mount),
Global: true,
Name: "licensing",
App: licensing.Mount,
}}, []string{"licensing"}); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
+2 -3
View File
@@ -17,9 +17,8 @@ import (
// link. The same app still mounts into the unified cloud binary via apps.Wire().
func main() {
if err := cloud.Serve([]cloud.MountSpec{{
Name: "metrics",
Mount: cloud.Global(cloud.MountMetrics),
Global: true,
Name: "metrics",
App: cloud.MountMetrics,
}}, []string{"metrics"}); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
+1 -1
View File
@@ -113,5 +113,5 @@ var Apps = []App{
{Name: "ask", Prefixes: []string{"/v1/ask"}},
{Name: "translate", Prefixes: []string{"/v1/translate"}},
{Name: "ai", Prefixes: []string{"/v1"}},
{Name: "plugins", Prefixes: []string{"/v1/plugins"}},
{Name: "plugins", Prefixes: []string{"/v1/admin/plugins"}},
}
+3 -12
View File
@@ -3,8 +3,6 @@
package cloud
import (
"fmt"
"github.com/zap-proto/zip"
)
@@ -22,7 +20,7 @@ import (
// private unix socket and mounts the routes onto it; the child is stopped when
// Shutdown runs, so a plugin subsystem tears down with the rest.
//
// Global is set because zip.Load registers under the prefix it was given. Handing
// App is set because zip.Load registers under the prefix it was given. Handing
// it a scoped Router would nest that prefix under the subsystem name and the
// routes would answer somewhere nobody is asking.
//
@@ -38,14 +36,7 @@ func PluginSpec(name string, p zip.Plugin, prefixes ...string) MountSpec {
p.Name = name
}
return MountSpec{
Name: name,
Global: true,
Mount: func(router Router, _ Deps) error {
app, ok := router.(*zip.App)
if !ok {
return fmt.Errorf("pluginspec %q: needs the root app, got %T — Global must stay set", name, router)
}
return zip.Load(p, prefixes...)(app)
},
Name: name,
App: func(app *zip.App, _ Deps) error { return zip.Load(p, prefixes...)(app) },
}
}
+9 -20
View File
@@ -3,7 +3,6 @@
package cloud
import (
"strings"
"testing"
"github.com/zap-proto/zip"
@@ -12,30 +11,20 @@ import (
// A plugin subsystem must look like every other one at the composition root:
// same MountSpec type, so Wire() can swap in-process for out-of-process by
// editing one line.
//
// It fills App, not Mount: zip.Load registers the prefix itself, so a scoped
// Router would nest it and the routes would answer under a doubled prefix. That
// used to be a runtime check with an error message; App takes *zip.App, so it
// is now unrepresentable and there is nothing left to test.
func TestPluginSpec_IsAnOrdinaryMountSpec(t *testing.T) {
s := PluginSpec("search", zip.Plugin{Addr: "127.0.0.1:1"}, "/v1/search")
if s.Name != "search" {
t.Fatalf("name = %q, want search", s.Name)
}
if s.Mount == nil {
t.Fatal("Mount is nil — the spec would silently mount nothing")
if s.App == nil {
t.Fatal("App is nil — the spec would silently mount nothing")
}
if !s.Global {
t.Fatal("Global must be set: zip.Load registers the prefix itself, and a scoped Router would nest it")
if s.Mount != nil {
t.Fatal("Mount must stay nil: a subsystem is scoped or global, not both")
}
}
// Mounting onto a scoped Router is a wiring mistake, not something to paper
// over: the routes would answer under a doubled prefix. Fail loudly.
func TestPluginSpec_RefusesAScopedRouter(t *testing.T) {
s := PluginSpec("bad", zip.Plugin{Addr: "127.0.0.1:1"}, "/v1/bad")
err := s.Mount(scopedStub{}, Deps{})
if err == nil {
t.Fatal("mounting on a non-root Router must fail")
}
if !strings.Contains(err.Error(), "Global") {
t.Fatalf("error should name the cause, got: %v", err)
}
}
type scopedStub struct{ Router }
+25 -20
View File
@@ -13,7 +13,7 @@ package cloud
// app.Group("/x", mw) // matches every path under /x
//
// Both go through Router, and Router is the only thing MountAll hands a subsystem
// that is not Global. Everything else — leaf routes, bare Groups, the *fiber.App
// that is not global. Everything else — leaf routes, bare Groups, the *fiber.App
// escape — passes through untouched, so absolute paths and fiber's most-specific-
// wins precedence are exactly what they were.
//
@@ -31,11 +31,11 @@ import (
// Router is the surface a subsystem mounts on: zip's routing methods, plus the
// *fiber.App escape the in-process dispatchers need (fiber.Test, GetRoutes,
// adaptor.FiberApp). *zip.App satisfies it as-is, so Serve can hand the bare app
// to a Global subsystem and tests can pass a raw app.
// to a global subsystem and tests can pass a raw app.
//
// Fiber() is a deliberate, named hole: it is the concrete engine, and middleware
// installed through it is app-wide. It is promoted onto the scoped Router rather
// than granting those subsystems Global — the alternative was four more Globals for
// than granting those subsystems App — the alternative was four more of them for
// four read-only uses. Its callers are greppable and none of them registers
// middleware.
type Router interface {
@@ -55,7 +55,27 @@ type Router interface {
Fiber() *fiber.App
}
// scope is the Router a non-Global subsystem mounts on. It holds the subsystem's
// ZipApp recovers the concrete *zip.App behind a Router. It is what zip's TYPED
// registrars (zip.Get[In, Out] and friends) take, because a typed op is a route
// PLUS a registry entry — the one value the OpenAPI document, the MCP tool list
// and the CLI are all projected from — and that registry lives on the App.
//
// This is the same deliberate hole as Fiber(), one level up, and it is safe for
// the same reason: registering an op is route registration, which scope has
// never bounded (it bounds middleware). nil means the Router is neither an App
// nor a scope, which no caller should paper over — a subsystem that cannot reach
// the registry must fail its mount rather than serve routes no projection knows.
func ZipApp(r Router) *zip.App {
switch v := r.(type) {
case *zip.App:
return v
case *scope:
return v.app
}
return nil
}
// scope is the Router a scoped subsystem mounts on. It holds the subsystem's
// declared prefixes and refuses to install middleware outside them.
type scope struct {
app *zip.App
@@ -128,21 +148,6 @@ func (s *scope) err() error {
return nil
}
return fmt.Errorf(
"%s installed middleware at %s, outside the prefixes it owns (%s) — declare those prefixes in its MountSpec, or Global: true if it really gates the whole binary",
"%s installed middleware at %s, outside the prefixes it owns (%s) — declare those prefixes in its MountSpec, or set App instead if it really gates the whole binary",
s.name, strings.Join(*s.escaped, ", "), strings.Join(s.prefixes, ", "))
}
// Global adapts a subsystem whose Mount still takes the concrete *zip.App into a
// MountFunc. It exists for the linked modules cloud does not own — they cannot take
// cloud.Router until their own module takes it — and it only works on a spec that
// also declares Global: true, because the bare app is the only Router that IS a
// *zip.App. Anything else fails the mount at boot rather than silently.
func Global(fn func(*zip.App, Deps) error) MountFunc {
return func(r Router, deps Deps) error {
app, ok := r.(*zip.App)
if !ok {
return fmt.Errorf("this subsystem takes the bare *zip.App; its MountSpec needs Global: true")
}
return fn(app, deps)
}
}
+6 -18
View File
@@ -161,16 +161,16 @@ func TestScopeAllowsGroupInsideItsPrefixes(t *testing.T) {
}
}
// TestGlobalIsTheOnlyAppWideDoor pins the asymmetry that makes the whole thing
// work: with Global the subsystem receives the bare *zip.App and its Use means what
// TestAppIsTheOnlyAppWideDoor pins the asymmetry that makes the whole thing
// work: with App the subsystem receives the bare *zip.App and its Use means what
// it has always meant. That is the capability, and it is spelled out in Wire().
func TestGlobalIsTheOnlyAppWideDoor(t *testing.T) {
func TestAppIsTheOnlyAppWideDoor(t *testing.T) {
app := newApp()
err := mountAll(t, app, []cloud.MountSpec{
{Name: "edge", Global: true, Mount: cloud.Global(func(a *zip.App, _ cloud.Deps) error {
{Name: "edge", App: func(a *zip.App, _ cloud.Deps) error {
a.Use(deny)
return nil
})},
}},
{Name: "neighbour", Mount: func(r cloud.Router, _ cloud.Deps) error {
r.Get("/v1/neighbour/ping", pong)
return nil
@@ -180,19 +180,7 @@ func TestGlobalIsTheOnlyAppWideDoor(t *testing.T) {
t.Fatalf("MountAll: %v", err)
}
if got := get(t, app, "/v1/neighbour/ping"); got != http.StatusUnauthorized {
t.Errorf("neighbour = %d, want 401 — Global middleware must still reach every route", got)
t.Errorf("neighbour = %d, want 401 — App middleware must still reach every route", got)
}
}
// TestGlobalMountNeedsTheGlobalFlag proves the adapter cannot be smuggled in
// without the declaration: a spec that takes the bare app but forgets Global: true
// fails the mount instead of silently receiving a scope it cannot use.
func TestGlobalMountNeedsTheGlobalFlag(t *testing.T) {
app := newApp()
err := mountAll(t, app, []cloud.MountSpec{
{Name: "edge", Mount: cloud.Global(func(*zip.App, cloud.Deps) error { return nil })},
})
if err == nil {
t.Fatal("MountAll succeeded — cloud.Global ran without Global: true")
}
}