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:
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user