Compare commits

...
Author SHA1 Message Date
hanzo-dev 18749656a2 refactor: MountSpec is AppSpec — they are apps, mount is the verb
Renamed across 18 files. The composition root lists apps; "mount" is what you do
to one, not what one is.

Also carried in, because both were blocking a build:
  - PluginSpec takes every prefix an app owns, not one. o11y owns /v1/o11y AND
    /v1/sentry (the Sentry-protocol ingest), and a prefix left out is not an
    error — it is a silent 404 on that subtree, which for Sentry ingest means
    quietly dropping every error event in the fleet.
  - clients/websearch used zip.AdaptNetHTTPFunc, removed in zip v1.12.0. main
    does not compile without this. Now AdaptNetHTTP(http.HandlerFunc(f)) —
    http.HandlerFunc already IS an http.Handler, which is why the func-shaped
    adapter was redundant.

NOT DONE, deliberately: currying Mount on Deps. Measured first — it costs 102
app signature changes across 103 files, and the payoff does not arrive. The
point of currying was that a cloud app would become a zip.Service and the
adapters would vanish; but cloud's Mount takes Router, not *zip.App, and that
distinction is load-bearing — newScope bounds which prefixes an app's middleware
may touch, and apps.TestWireFrozen fails on any new Global grant (only 6 of ~108
apps hold one). Curried, PluginSpec STILL needs its type assertion. 102
signatures for an adapter that stays is not a trade worth making; unifying the
router types first would be, and that is a different change.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 16:04:21 -07:00
hanzo-dev 7fcf19709a cd+site: delete the duplicate lifecycle; keep what it found
clients/projects already owns the versioned-release model for sites, and
does it better than the packages this removes:

  <org>/.releases/<slug>/rel_<manifest digest>/  immutable, content-addressed,
  a SIBLING of the mutable prefix so a purge cannot shred a live release;
  ActivateRelease is one atomic UPDATE ... WHERE EXISTS (release row);
  servePrefix re-validates the id before it can widen a prefix and falls back
  to the legacy prefix, so there is no flag day; rollback is activating an
  older id, already routed.

clients/cd + clients/site re-implemented that with a WEAKER pointer: a v<N>
counter instead of a content digest, and a plain PUT of a CURRENT object that
could name a release whose row does not exist. Shipping it would have been a
second way to do a solved thing.

The premise it was built on was also wrong: hanzoai/static was never going to
need to resolve CURRENT, because it does not resolve the pointer at all —
clients/sites does, through the store. There was no ordering constraint and no
flag day to sequence.

What it genuinely found is kept in LLM.md as a named gap: releases are never
garbage-collected, and once retention exists activate must verify the BYTES,
not just the row, before it flips. clients/platform/handoff.go goes with it —
its digest-over-tag insight is real but it was scaffolding for this
architecture and never became load-bearing.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 15:47:04 -07:00
hanzo-dev d3f91115c5 platform: one writer for "what is live"
rolloutRelease wrote twice — the operator Service CR patch plus a
repository_dispatch mirror at hanzoai/universe — composed best-effort so
the step passed if EITHER landed. Two writers for one fact, and the
composition hid their disagreement: the patch fails, the mirror succeeds,
and the cluster and git describe different production states with nothing
reporting a problem.

The mirror was never actually running. It reads UNIVERSE_DISPATCH_TOKEN,
which is not set on the cloud deployment (197 env vars; only GH_PAT is
there), so it failed closed on every release. Every rollout in production
was already the CR patch alone. This deletes a phantom second writer, not
a second path.

With one writer the remaining failure is reported instead of tolerated: a
release that cannot patch the CR has built, smoke-passed and tagged an
image that is NOT live, and saying otherwise is worse than failing.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 15:42:00 -07:00
hanzo-dev d699a0e297 cd: the registry — one name, one owner
Kinds register themselves; cd imports none of them and never branches on Kind.
Adding "function" or "worker" later is a Register call, not an edit to the
lifecycle — the property that stops the per-kind pipelines from growing back.

A duplicate name is an error rather than a silent overwrite. Two registrations
for one name mean two owners for one deployment, which is the same two-writer
confusion that let the CR patch and the universe mirror disagree about what is
live. Failing at mount is loud and early; overwriting would surface much later
as a rollout that mysteriously went somewhere else.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 15:24:59 -07:00
hanzo-dev be883e4659 platform: name the CI->CD seam, and the two writers it hides
releaseFor is build -> smoke -> tag -> notify, but only the first three answer
CI's question ("is this good, and what did it produce?"). notify answers CD's
("what is live?") — and answers it TWICE: it patches the Service CR directly AND
mirrors an image update into universe, with a fallback between them. So "what is
live" has two writers that can disagree, and the fallback hides it: the CR patch
fails, the mirror succeeds, and the cluster and git describe different production
states with nothing reporting a problem.

That is also why a release cannot be rolled back. Neither writer records WHICH
artifact was placed, only that it was pushed somewhere; rollback needs the set of
placements and no one was keeping it.

A release run produces exactly one durable fact: a verified, immutable artifact
at a known reference. That is where CI ends. Artifact() expresses it in cd's
vocabulary and is deliberately the ONLY thing handed over — a seam this small
means the runner cannot regrow deployment opinions.

It is built only after smoke passes, because an unverified image handed to cd
would become a Placement and therefore a rollback target someone selects during
an incident. When a digest is known the reference uses it: a tag can be moved, a
digest cannot, and a rollback resolving a moved tag deploys different bytes than
the ones that were verified.

NOT LOAD-BEARING YET: rolloutRelease still runs. This makes the switch a one-line
change once cd is mounted and a workload Target is registered.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 13:40:53 -07:00
hanzo-dev 73f66218b5 cd+site: one deploy lifecycle, and a live pointer that makes rollback possible
"Get code to production" was five mechanisms — platform/v1/runner, cloud/projects,
hanzocd, universe-git-sync, and hand-rolled Deployments — each re-deriving how to
build, where to put the result, how to version it, and how to undo it. Braiding
those four independent facts is why a static site could not be rolled back, did
not appear in the platform UI, and was not tracked by CD at all.

clients/cd is the lifecycle, and it is ignorant on purpose: an Artifact being an
image or a bundle is a property of the BUILD OUTPUT, not a different lifecycle —
a container and a site differ in how bytes are produced and served, never in what
"promote" or "roll back" mean. So there is one Engine and a Target interface, and
cd holds no compiled dependency on any kind; adding one never edits the package.
Deploy and Rollback are the SAME function with a different Placement, which falls
out of releases being immutable and removes the code path that only runs during
an incident and is therefore only tested during one. Place always completes
before Activate flips, so a broken build can fail to replace production but can
never take it down.

clients/site implements the origin kind. The old publishSite wrote every deploy
to the SAME prefix "<org>/<slug>/", and BindHost mapped a host to (org, slug) —
so "which release is live" was never a stored fact, just whatever bytes were
written last. That absence, not a missing feature, is the structural reason
rollback did not exist. Bundles now land at "<org>/<slug>/v<N>/" and one object,
"<org>/<slug>/CURRENT", names the live one: a single atomic PUT, in the same
failure domain as the bytes it names, readable with a plain object GET.

NOT YET WIRED: no routes are mounted and no writer is switched. Cutting live
sites over requires the static server to resolve CURRENT first, or a site would
404 on its next deploy.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 13:23:48 -07:00
hanzo-dev 2f683d0625 feat(o11y): the first cloud app that is not linked in
o11y builds as its own binary and the composition root loads it at run time
(cloud.PluginSpec -> zip.Load) instead of linking it. The subsystem's code did
not move and did not fork: cmd/o11y calls the same o11y.MountO11y the Wire
entry used to. Only the process changed, which is the point — where a
subsystem runs becomes a deployment decision rather than a property of the
source.

Deleting the clients/o11y IMPORT is what does the work; dropping the mount
alone would have kept the graph linked.

Measured on this tree (interleaved A/B, 3 rounds, on a contended box):

  cmd/cloud packages   4336 -> 3250   (-1086, -25.0%)
  cmd/cloud binary     496.6 -> 401.3 MiB (-95.3 MiB, -19.2%)
  cmd/cloud link RSS   5.50 -> 4.48 GiB   (-18.6%, no overlap across rounds)
  cmd/cloud relink     21.58s -> 17.36s min (noisy; still a 3250-pkg link)

  o11y plugin          2734 packages, 165.1 MiB, 7.8s, 2.49 GiB peak

The real win is not cloud's link time, it is that an o11y change no longer
relinks cloud AT ALL: 7.8s/2.49 GiB against 21.6s/5.50 GiB, and the host is
untouched.

Proven end to end: cloud starts the child on a private unix socket and
/v1/o11y/* is answered by it — /v1/o11y/status returns o11y's own 403 while
the host binary contains ZERO clients/o11y symbols (nm: 450 -> 0).

Tests: cmd/cloud gains a TestMain that builds the plugin, because MountAll can
no longer mount o11y without it, and newTestApp/fullyMountedApp now shut the
app down — a mounted plugin is a child process, and leaking it holds the test's
stdout open until `go test` fails a suite that passed.

The frozen wire golden flips o11y's hasShutdown true->false: teardown moved
into the child with the resources. Name/OwnsHealth/Global stay pinned.

KNOWN GAPS, not merge-ready (see branch report):
  - /v1/sentry/* is a second prefix o11y owns; zip.Load takes one, so it 404s.
  - the host loses its OTel tracer provider: clients/o11y's init() registered
    it (nm: installTraceProvider 24 -> 0). The bootstrap is a HOST concern
    braided into the o11y package and must be split out before merge.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 12:48:55 -07:00
26 changed files with 351 additions and 164 deletions
+44 -1
View File
@@ -460,9 +460,52 @@ as an empty index.
`.github/workflows` is intentionally empty of CI. The image and its `v*` tags have ONE
owner, `clients/platform/release.go`: compute the next version → build → SMOKE the
pushed image → tag → notify universe. The tag is a RECEIPT for a proven image, so a
pushed image → tag → roll out. The tag is a RECEIPT for a proven image, so a
change that breaks boot never reaches production and leaves no phantom tag.
The final step has ONE writer: patch the operator `hanzo.ai/v1` Service CR's
`spec.image` and let the operator reconcile. It used to write twice — that patch plus
a `repository_dispatch` mirror at `hanzoai/universe` — composed best-effort so the
step passed if EITHER landed. Two writers for one fact, and the composition HID their
disagreement: patch fails, mirror succeeds, cluster and git now describe different
production states with nothing reporting a problem. The mirror was also never running
— it read `UNIVERSE_DISPATCH_TOKEN`, never set on the deployment, so it failed closed
on every release and the CR patch was already doing all the work. A rollout with
nowhere to write is now an ERROR: the image is built, smoke-passed and tagged but NOT
live, and a release that claims otherwise is worse than one that fails.
### Site releases already have a lifecycle — do not build a second one
`clients/projects` owns the full versioned-release model for static sites, and it is
the ONE way:
- `<org>/.releases/<slug>/rel_<128-bit manifest digest>/` — immutable, content-
addressed, and a SIBLING of the mutable `<org>/<slug>/` prefix, so neither a
full-artifact deploy nor a project delete (both of which purge that subtree) can
shred a release the pointer still names.
- `Store.ActivateRelease` — the flip is one atomic `UPDATE … WHERE EXISTS (release
row)`, so it cannot point a site at a release that was never created, and two
concurrent activations cannot leave the pointer disagreeing with whichever won.
`MarkLive` deliberately does NOT touch `current_release`.
- `servePrefix` (`clients/projects/sites.go`) — the ONE read rule, re-validating the
id against `releaseIDRE` before it can widen a prefix. An unrecognized id falls back
to the legacy prefix, so there is no flag day and no migration.
- Rollback is activating an older id. Routes are already mounted on both site
surfaces via `siteReleases`.
A parallel `clients/cd` + `clients/site` lifecycle (kind-agnostic `Target`, a
`CURRENT` pointer object next to the bundles) was built and then DELETED unmerged: it
re-implemented all of the above with a weaker pointer — a `v<N>` counter instead of a
content digest, and a plain PUT that could name a release whose row does not exist.
Its one genuinely new finding is recorded as a gap below, not as a second system.
**Known gap: releases are never garbage-collected.** `promote` writes a new immutable
prefix per distinct content and nothing prunes them; `DeleteReleases` only runs on
project delete. Retention belongs in `clients/projects` next to `promote`. When it is
added, `activate` must also verify the bytes still exist before flipping — today
`ActivateRelease` proves only that the ROW exists, which is sufficient only while
nothing can prune the bytes out from under it.
It is driven by the GitHub App. A push arrives HMAC-verified at
`/v1/connector/github/webhook`, which fires `cloud.OnGitPush` — the SAME
single-registrant seam the embedded git server uses, not a second CI — and
+62 -13
View File
@@ -1,7 +1,7 @@
// Package apps is the composition root: the single, explicit list of which
// Hanzo cloud subsystems are linked into the binary AND the order they mount in.
//
// Wire() returns []cloud.MountSpec in mount order (slice position == order). There
// Wire() returns []cloud.AppSpec in mount order (slice position == order). There
// is no init()-registry and no order-int: adding, removing, or reordering a
// subsystem is a one-line edit to Wire(), read top-to-bottom. cmd/cloud and
// cmd/hanzo both call Wire() and thread the slice into cloud.Serve — the set is
@@ -38,6 +38,9 @@ package apps
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/hanzoai/cloud"
"github.com/zap-proto/zip"
@@ -113,7 +116,12 @@ import (
"github.com/hanzoai/cloud/clients/marketplace"
"github.com/hanzoai/cloud/clients/ml"
"github.com/hanzoai/cloud/clients/notify"
"github.com/hanzoai/cloud/clients/o11y"
// NOTE: clients/o11y is deliberately NOT imported. It is loaded at run time
// as a plugin (see the o11y entry in Wire), and this line is the whole reason
// that works: an import here would keep its 2.7k-package graph — the
// otel-collector, prometheus, gonum — linked into cloud whether or not any
// Wire entry referenced it. Unlinking a subsystem means deleting its import,
// not just its mount.
"github.com/hanzoai/cloud/clients/paas"
"github.com/hanzoai/cloud/clients/plan"
"github.com/hanzoai/cloud/clients/platform"
@@ -188,13 +196,13 @@ func init() {
})
}
// Wire returns every linked subsystem as a cloud.MountSpec, in mount order. The
// Wire returns every linked subsystem as a cloud.AppSpec, in mount order. The
// slice position IS the order: cloud.MountAll iterates it as-given, registering each
// subsystem's teardown as a zip shutdown hook so teardown runs in reverse (LIFO).
// Enablement is a separate axis: cloud.Serve mounts only the specs cfg.Enabled(name)
// admits, so a STAGED subsystem is linked but inert until named.
func Wire() []cloud.MountSpec {
return []cloud.MountSpec{
func Wire() []cloud.AppSpec {
return []cloud.AppSpec{
// embedded NATS :4222 + JetStream.
{Name: "pubsub", Mount: pubsub.Mount, Shutdown: pubsub.Shutdown},
// embedded Kafka adaptor :9092.
@@ -228,14 +236,29 @@ func Wire() []cloud.MountSpec {
// Embedded Base app engine + viral waitlist (/v1/waitlist/*). STAGED behind
// CLOUD_BASE_EMBED. OwnsHealth: native /v1/base/health.
{Name: "base", Mount: base.Mount, Shutdown: base.Shutdown, OwnsHealth: true},
// The ONE observability subsystem: the in-repo o11y READ plane + runtime-handler
// install (o11y.SetHandler), with the hanzoai/o11y module wildcard /v1/o11y/*
// folded in as the TERMINAL sub-mount INSIDE o11y.MountO11y. Every specific
// /v1/o11y/* route registers before that wildcard, so Fiber's in-order match gives
// them precedence. NOT OwnsHealth: /v1/o11y/health stays the generic always-ok
// route (registered before MountAll), exactly as when the former module co-entry —
// which also set OwnsHealth=false — triggered it.
{Name: "o11y", Mount: cloud.Global(o11y.MountO11y), Shutdown: o11y.ShutdownO11y, Global: true},
// The ONE observability subsystem — and the first one that is NOT linked in.
// It runs as its own binary (cmd/o11y) and mounts at /v1/o11y over a private
// unix socket; the whole read plane, the runtime handler, the OTLP collector
// and the trace sink moved into that process untouched (it calls the same
// o11y.MountO11y). Nothing about the ROUTES changed: the plugin's own in-order
// registration still puts every specific /v1/o11y/* route ahead of the
// hanzoai/o11y module wildcard, and NOT OwnsHealth still leaves /v1/o11y/health
// the generic always-ok route Serve registers before MountAll — which therefore
// still wins over this mount's /v1/o11y/* and answers without waking the child.
//
// No Shutdown: teardown moved with the resources. zip.Load registers its own
// OnShutdown that stops the child, and the child flushes its collector/sink in
// its own app.OnShutdown. The host has nothing left of o11y's to close.
//
// Why this one first: o11y is the heaviest app in the graph — the
// otel-collector, prometheus and gonum are here and nowhere else — and it is
// imported by NOTHING but this line, so unlinking it is a pure subtraction.
//
// o11y owns TWO public prefixes — /v1/o11y and /v1/sentry/* (mountSentry,
// the Sentry-protocol ingest). Both are named here: a prefix left out
// would 404 silently rather than fail, which for Sentry ingest means
// quietly dropping every error event in the fleet.
cloud.PluginSpec("o11y", o11yPlugin(), "/v1/o11y", "/v1/sentry"),
{Name: "authz", Mount: cloud.Global(authz.Mount), Global: true},
// Embedded commerce plane /v1/commerce/*, /_/commerce/* — the hanzoai/commerce
// MODULE via the adapter in commerce.go (un-forked; the in-process
@@ -545,6 +568,32 @@ func ServeSingle(name string) error {
return fmt.Errorf("ServeSingle: unknown app %q — run `hanzo code ls`/`hanzo` for the list", name)
}
// o11yPlugin says where to find the o11y binary. Its two knobs map 1:1 onto
// zip.Plugin's own fields, so there is no third notion of "where a plugin is"
// and nothing to translate:
//
// CLOUD_O11Y_ADDR — already listening there; start nothing, just mount it.
// CLOUD_O11Y_BIN — the binary's path on disk.
//
// The default is a file named "o11y" beside the running cloud binary, which is
// the container layout: both binaries in the image, still one artifact to ship.
// Resolving it from os.Executable rather than $PATH means a host always loads
// the o11y it was built and shipped with, not whichever one a PATH happens to
// find.
func o11yPlugin() zip.Plugin {
if addr := strings.TrimSpace(os.Getenv("CLOUD_O11Y_ADDR")); addr != "" {
return zip.Plugin{Addr: addr}
}
path := strings.TrimSpace(os.Getenv("CLOUD_O11Y_BIN"))
if path == "" {
path = "o11y"
if self, err := os.Executable(); err == nil {
path = filepath.Join(filepath.Dir(self), "o11y")
}
}
return zip.Plugin{Path: path}
}
// mountMetrics adapts hanzoai/metrics into a cloud.MountFunc. Unlike the other
// externals, metrics declares its OWN narrow Deps (Logger, DataDir, Brand) and does
// not import hanzoai/cloud, so cloud.Typed cannot bridge it: the composition root
+1 -1
View File
@@ -17,7 +17,7 @@ import (
"github.com/hanzoai/cloud/clients/principal"
)
// wire_seams.go wires cross-subsystem in-process seams that cannot be a MountSpec
// wire_seams.go wires cross-subsystem in-process seams that cannot be a AppSpec
// because they compose functions ACROSS packages that must not import each other.
//
// The coding orchestrator (clients/coding) needs git's CloneURL + VerifyRef, but
+9 -2
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 AppSpec.Global
}{
{"pubsub", false, true, false}, // was order 5
{"kafka", false, true, false}, // was order 6
@@ -34,7 +34,14 @@ var frozen = []struct {
{"account", false, false, false}, // was order 48
{"iam", false, false, false}, // was order 50
{"base", true, true, false}, // was order 60; per-org embed added Shutdown (#298)
{"o11y", false, true, true}, // ONE observability subsystem (was co-owned orders 69+70): read plane + the hanzoai/o11y module wildcard folded in as MountO11y's terminal sub-mount. OwnsHealth=false keeps /v1/o11y/health the generic always-ok route the module co-entry used to trigger.
// hasShutdown flipped true->false when o11y became a PLUGIN (cloud.PluginSpec,
// its own cmd/o11y binary). Deliberate and load-bearing, not drift: the host no
// 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 —
// 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
{"commerce", false, false, true}, // was order 100
{"licensing", false, false, true}, // was order 110
+4 -4
View File
@@ -963,7 +963,7 @@ func pickVaultClient(cfg *Config, log luxlog.Logger) VaultClient {
// signature, so Wire references each one directly and the compiler checks it.
//
// app is a Router, not the concrete *zip.App, and that is the whole safety
// property: middleware a subsystem installs lands on the subtrees its MountSpec
// property: middleware a subsystem installs lands on the subtrees its AppSpec
// 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.
@@ -975,11 +975,11 @@ type MountFunc func(app Router, deps Deps) error
// deadline so a slow teardown is cut off rather than hanging SIGTERM.
type ShutdownFunc func(ctx context.Context) error
// MountSpec describes one subsystem to mount. There is NO Order field: the slice
// AppSpec describes one subsystem to mount. There is NO Order field: the slice
// position in apps.Wire() IS the mount order — the composition root lists
// subsystems in the exact sequence they mount (and, reversed, tear down), so order
// is data read top-to-bottom in one file, not ints scattered across the tree.
type MountSpec struct {
type AppSpec struct {
Name string
Mount MountFunc
Shutdown ShutdownFunc // optional; nil means the subsystem has nothing to tear down.
@@ -1020,7 +1020,7 @@ type MountSpec struct {
// its dependents is torn down after them) with no subsystem torn down while a
// request still uses it. Only ENABLED specs mount, so only they register a hook;
// teardown needs no separate enablement gate.
func MountAll(app *zip.App, specs []MountSpec, cfg *Config, deps Deps) error {
func MountAll(app *zip.App, specs []AppSpec, cfg *Config, deps Deps) error {
logger := deps.Logger
for _, spec := range specs {
if !cfg.Enabled(spec.Name) {
+2 -2
View File
@@ -70,7 +70,7 @@ func TestMountAll_ShutdownHooksLIFOAfterDrain(t *testing.T) {
}
// Mount order a, b, c ⇒ LIFO teardown must be c, b, a.
specs := []cloud.MountSpec{
specs := []cloud.AppSpec{
{Name: "a", Mount: noopMount, Shutdown: record("a")},
{Name: "b", Mount: noopMount, Shutdown: record("b")},
{Name: "c", Mount: noopMount, Shutdown: record("c")},
@@ -183,7 +183,7 @@ func TestMountAll_ShutdownRegistration_EnablementAndNil(t *testing.T) {
}
}
specs := []cloud.MountSpec{
specs := []cloud.AppSpec{
{Name: "enabled", Mount: noopMount, Shutdown: record("enabled")},
{Name: "disabled", Mount: noopMount, Shutdown: record("disabled")},
{Name: "nilsd", Mount: noopMount}, // enabled, but no Shutdown
+1 -1
View File
@@ -39,7 +39,7 @@ import (
// drive the SAME single implementation. The subsystem is a stateless orchestrator over
// framework (which holds the state) + the AI/social edges — it opens no store of its own.
//
// Registration is a one-line cloud.MountSpec in apps.Wire() (after framework +
// Registration is a one-line cloud.AppSpec in apps.Wire() (after framework +
// knowledge, before the AI /v1/* catch-all); the module fixtures + lifecycle hooks are
// registered in doctypes.go's init(), process-global and mount-order-independent.
+1 -1
View File
@@ -63,7 +63,7 @@ import (
// Prefixes are the canonical absolute prefixes the IAM identity surface owns —
// the ONE list. It registers the real routes (safeMount), serves the fail-closed 503
// when IAM cannot boot, and is the MountSpec.Prefixes apps.Wire() hands MountAll, so
// when IAM cannot boot, and is the AppSpec.Prefixes apps.Wire() hands MountAll, so
// IAM's middleware can only ever land on identity's own subtrees. Everything outside
// them belongs to cloud, so the console catch-all keeps serving the SPA.
//
+3 -3
View File
@@ -1,7 +1,7 @@
package kms_test
// Integration tests for the embedded KMS subsystem, exercised through the REAL
// orchestrator path (BuildDeps → the init()-registered MountSpec → the zip/Fiber
// orchestrator path (BuildDeps → the init()-registered AppSpec → the zip/Fiber
// stack), mirroring cmd/cloud/main_test.go. Requests run in-process via
// app.Fiber().Test — no listener, no external KMS, no PostgreSQL.
//
@@ -49,8 +49,8 @@ func masterKeyB64(t *testing.T) string {
// mountSpecs is the kms subsystem's composition-root entry, built locally so these
// tests mount exactly kms (the same spec apps.Wire() carries) without linking
// the whole bundle. cfg.Enable still gates it, exactly as in production.
func mountSpecs() []cloud.MountSpec {
return []cloud.MountSpec{{Name: "kms", Mount: kms.Mount, OwnsHealth: true}}
func mountSpecs() []cloud.AppSpec {
return []cloud.AppSpec{{Name: "kms", Mount: kms.Mount, OwnsHealth: true}}
}
// newApp wires BuildDeps + the canonical middleware + MountAll for the kms
+1 -1
View File
@@ -36,7 +36,7 @@ func newDualApp(t *testing.T, mk string) *zip.App {
app.Use(middleware.Recover())
app.Use(middleware.RequestID())
app.Use(middleware.Logger(deps.Logger))
specs := []cloud.MountSpec{
specs := []cloud.AppSpec{
{Name: "kms", Mount: kms.Mount, OwnsHealth: true},
{Name: "admin", Mount: admin.Mount},
}
+22 -61
View File
@@ -40,15 +40,13 @@ import (
const (
// releaseImage is the ONE image cloud self-publishes; releaseRepoSlug/URL name
// its source; releaseFloor is the version floor for the first release ever
// (mirrors release.yml). universeRepo receives the image-update dispatch.
// (mirrors release.yml).
releaseImage = "ghcr.io/hanzoai/cloud"
releaseRepoSlug = "hanzoai/cloud"
releaseRepoURL = "https://github.com/hanzoai/cloud"
releaseFloor = "1.786.0"
universeRepo = "hanzoai/universe"
// releaseServiceName is the operator Service CR metadata.name for cloud's own
// self-publish (crs/cloud.yaml) — the target of both the native CR rollout and
// the image-update mirror, so the two never name different CRs.
// self-publish (crs/cloud.yaml) — the target of the CR rollout.
releaseServiceName = "cloud"
)
@@ -290,38 +288,31 @@ func releaseFor(s *cloud.Service[state], repoURL, sha, image, tag, dockerfile, b
// rolloutRelease rolls the proven image live. It is the release pipeline's final
// step, reached only AFTER the tag receipt is minted (build + smoke passed).
//
// PRIMARY — native CR rollout: patch the operator hanzo.ai/v1 Service CR's
// spec.image directly (cloud.OnServiceRelease → clients/paas releaseService), so
// the operator reconciles the Deployment. No ArgoCD, no repository_dispatch, no
// git round-trip — the direct-CR seam this closes.
// ONE WRITER: patch the operator hanzo.ai/v1 Service CR's spec.image
// (cloud.OnServiceRelease → clients/paas releaseService) and let the operator
// reconcile the Deployment. No ArgoCD, no repository_dispatch, no git round-trip.
//
// MIRROR — GitOps: also fire the image-update dispatch at universe
// (notifyUniverse) so any environment still reconciled by the git/ArgoCD pipeline
// stays in sync during the cutover.
// It used to write TWICE — the CR patch plus a repository_dispatch mirror at
// hanzoai/universe — composed best-effort so the step passed if EITHER landed.
// That made "what is live" a question with two answers that could disagree, and
// the composition hid the disagreement: the patch fails, the mirror succeeds, and
// the cluster and git now describe different production states with nothing
// reporting a problem. The mirror was also never actually running — it reads
// UNIVERSE_DISPATCH_TOKEN, which is not configured on the cloud deployment, so it
// failed closed on every release and every rollout in production was already the
// CR patch alone. Deleting it removes a phantom second writer, not a second path.
//
// Best-effort composition: the step succeeds if EITHER path rolled the image, so a
// missing cloud-api CR-patch RBAC (native) or a missing dispatch token (GitOps)
// alone never fails a release that already produced a proven, tagged image.
// The remaining failure is therefore reported honestly rather than tolerated: if
// the CR patch cannot happen, the image is built, smoke-passed and tagged but NOT
// live, and a release that says otherwise is worse than one that fails.
func rolloutRelease(s *cloud.Service[state], ctx context.Context, image, sha string) error {
var crErr error
if cloud.ServiceReleaserRegistered() {
crErr = cloud.OnServiceRelease(ctx, cloud.ServiceReleaseEvent{Service: releaseServiceName, Image: image, SHA: sha})
if crErr == nil {
s.Log.Info("release rolled out via operator CR patch (native)", "service", releaseServiceName, "image", image)
} else {
s.Log.Warn("native CR rollout failed; relying on the GitOps mirror", "service", releaseServiceName, "image", image, "err", crErr)
}
} else {
crErr = fmt.Errorf("paas control plane not co-resident (no native CR rollout)")
if !cloud.ServiceReleaserRegistered() {
return fmt.Errorf("paas control plane not co-resident: no CR releaser registered, image %s is tagged but NOT live", image)
}
nuErr := notifyUniverse(s, ctx, image, sha)
if nuErr != nil {
s.Log.Warn("universe image-update mirror failed", "image", image, "err", nuErr)
}
if crErr != nil && nuErr != nil {
return fmt.Errorf("rollout failed on both paths: native=%v; gitops=%v", crErr, nuErr)
if err := cloud.OnServiceRelease(ctx, cloud.ServiceReleaseEvent{Service: releaseServiceName, Image: image, SHA: sha}); err != nil {
return fmt.Errorf("roll out %s via operator CR patch: %w", releaseServiceName, err)
}
s.Log.Info("release rolled out via operator CR patch", "service", releaseServiceName, "image", image)
return nil
}
@@ -571,36 +562,6 @@ func tagRelease(s *cloud.Service[state], ctx context.Context, repo, sha, tag str
return nil
}
// notifyUniverse fires the image-update repository_dispatch at hanzoai/universe so the
// GitOps pipeline rolls the proven image — the SAME image-update contract every
// service uses (release.yml's notify-universe). Runs ONLY after the tag is minted, so
// universe is never asked to deploy a phantom tag. Token is UNIVERSE_DISPATCH_TOKEN
// from env (KMS-provisioned); fail closed if unset.
func notifyUniverse(s *cloud.Service[state], ctx context.Context, image, sha string) error {
tok := getenv("UNIVERSE_DISPATCH_TOKEN", "")
if tok == "" {
return fmt.Errorf("no UNIVERSE_DISPATCH_TOKEN configured")
}
body := map[string]any{
"event_type": "image-update",
"client_payload": map[string]string{
"service": "cloud",
"image": image,
"sha": sha,
"env": "all",
},
}
code, err := githubJSON(s, ctx, http.MethodPost, "/repos/"+universeRepo+"/dispatches", tok, body, nil)
if err != nil {
return err
}
if code != http.StatusNoContent {
return fmt.Errorf("notify universe: status %d", code)
}
s.Log.Info("universe notified (image-update)", "image", image, "sha", sha)
return nil
}
// ── shared GitHub seam ───────────────────────────────────────────────────────
// ghToken is the GitHub PAT for the release seams (list tags, resolve commit, mint
+20 -34
View File
@@ -344,46 +344,32 @@ func TestTagRelease_RefPath(t *testing.T) {
}
}
// The universe contract: image-update dispatch with {service,image,sha,env} — the
// SAME payload release.yml's notify-universe fires.
func TestNotifyUniverse_Payload(t *testing.T) {
t.Setenv("UNIVERSE_DISPATCH_TOKEN", "disp-token")
var got map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/repos/hanzoai/universe/dispatches" {
w.WriteHeader(http.StatusNotFound)
return
}
_ = json.NewDecoder(r.Body).Decode(&got)
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
defer swapAPIBase(srv.URL)()
img := "ghcr.io/hanzoai/cloud:v1.786.44"
if err := notifyUniverse(testService(), context.Background(), img, "deadbeef"); err != nil {
t.Fatalf("notifyUniverse: %v", err)
}
if got["event_type"] != "image-update" {
t.Fatalf("event_type: %v", got["event_type"])
}
cp, _ := got["client_payload"].(map[string]any)
if cp["service"] != "cloud" || cp["image"] != img || cp["env"] != "all" || cp["sha"] != "deadbeef" {
t.Fatalf("client_payload wrong: %v", cp)
}
}
// Fail-closed: the tag + notify seams refuse when their KMS-provisioned token is
// unset — no half-published release against an anonymous request.
// Fail-closed: the tag seam refuses when its KMS-provisioned token is unset — no
// half-published release against an anonymous request.
func TestReleaseSeams_FailClosedWithoutTokens(t *testing.T) {
t.Setenv("GH_PAT", "")
t.Setenv("UNIVERSE_DISPATCH_TOKEN", "")
s := testService()
if err := tagRelease(s, context.Background(), releaseRepoSlug, "sha", "v1.0.0"); err == nil {
t.Fatal("tagRelease with no GH_PAT: want fail-closed error")
}
if err := notifyUniverse(s, context.Background(), "img", "sha"); err == nil {
t.Fatal("notifyUniverse with no token: want fail-closed error")
}
// A rollout with nowhere to write must FAIL, not report success.
//
// This is the property the deleted GitOps mirror destroyed: the old composition
// passed the step if either writer landed, so an unrollable release still looked
// released. With one writer there is one answer, and "the image is tagged but not
// live" is an error — the state a release must never silently claim to have left.
func TestRolloutFailsWhenThereIsNowhereToWrite(t *testing.T) {
if cloud.ServiceReleaserRegistered() {
t.Skip("a releaser is registered in this process; the no-writer path is unreachable")
}
err := rolloutRelease(testService(), context.Background(), "ghcr.io/hanzoai/cloud:v1.0.0", "sha")
if err == nil {
t.Fatal("rolloutRelease with no registered releaser: want an error, got nil")
}
if !strings.Contains(err.Error(), "NOT live") {
t.Errorf("error should say the image is not live, got: %v", err)
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
package storage_test
// Integration tests for the /v1/s3 file-manager subsystem, driven through the
// REAL orchestrator path (BuildDeps → the init()-registered MountSpec → the
// REAL orchestrator path (BuildDeps → the init()-registered AppSpec → the
// zip/Fiber stack), exactly like clients/kms/kms_test.go. Requests run in-process
// via app.Fiber().Test — no listener, no live SeaweedFS.
//
@@ -67,7 +67,7 @@ func newApp(t *testing.T, creds bool) *zip.App {
deps := cloud.BuildDeps(cfg)
app := zip.New(zip.Config{Logger: deps.Logger})
app.Use(middleware.Recover())
specs := []cloud.MountSpec{
specs := []cloud.AppSpec{
{Name: "storage", Mount: storage.Mount, OwnsHealth: true},
{Name: "provisioning", Mount: provisioning.Mount},
}
+1 -1
View File
@@ -315,7 +315,7 @@ func Mount(app cloud.Router, deps cloud.Deps) error {
return searchKeyed(c)
})
scrape := zip.AdaptNetHTTPFunc(scrapeHandler)
scrape := zip.AdaptNetHTTP(http.HandlerFunc(scrapeHandler))
// Firecrawl builds {apiUrl}/{version}/scrape; pin firecrawlVersion:v1 so the
// client POSTs /v1/websearch/v1/scrape. Also accept the bare /scrape.
g.Post("/v1/scrape", scrape)
+79
View File
@@ -8,6 +8,10 @@ package main
import (
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
"github.com/hanzoai/cloud"
@@ -16,6 +20,75 @@ import (
"github.com/zap-proto/zip/middleware"
)
// TestMain supplies the two things MountAll(apps.Wire()) needs from its
// environment and cannot invent for itself.
//
// The dev KMS key is the same 32 zero bytes the root package's TestMain and
// clients/{esign,translate} already inject: subsystems with an encrypted store
// (pricing, o11y's annotation queues) refuse to open a data plane unencrypted,
// which is correct, and a test box has no KMS. One dev-only key, one pattern.
//
// The o11y binary is new, and is the cost of a plugin subsystem: o11y is no
// longer linked in, so the composition root can only mount it by starting the
// real binary. Building it here is deliberate — the alternative, a stub, would
// let these tests pass against a route table no deployment ever serves. Cached
// after the first run. If it cannot be built the dependent tests fail with
// zip's own fork/exec message, which names the missing file.
func TestMain(m *testing.M) { os.Exit(runTests(m)) }
// runTests exists so the temp dirs are removed on the way out: os.Exit does not
// run deferred functions, so TestMain cannot both clean up and set the code.
func runTests(m *testing.M) int {
if os.Getenv("CLOUD_KMS_MASTER_KEY_REF") == "" {
_ = os.Setenv("CLOUD_KMS_MASTER_KEY_REF", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") // dev-only
}
// The plugin child inherits this; without it the child would target the
// production /var/lib/cloud and die opening its store.
if os.Getenv("CLOUD_DATA_DIR") == "" {
dir, err := os.MkdirTemp("", "cloud-test-data-")
if err != nil {
return 1
}
defer os.RemoveAll(dir)
_ = os.Setenv("CLOUD_DATA_DIR", dir)
}
if os.Getenv("CLOUD_O11Y_BIN") == "" {
dir, err := os.MkdirTemp("", "cloud-test-plugins-")
if err != nil {
return 1
}
defer os.RemoveAll(dir)
bin := filepath.Join(dir, "o11y")
// GOROOT/bin/go, not "go": the toolchain that is running this test is the
// one that must build the plugin, and it is not always on PATH.
cmd := exec.Command(goTool(), "build", "-o", bin, "./cmd/o11y")
cmd.Dir = "../.." // the package dir is cmd/cloud
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
os.Stderr.WriteString("TestMain: building the o11y plugin failed: " + err.Error() + "\n")
} else {
_ = os.Setenv("CLOUD_O11Y_BIN", bin)
}
}
code := m.Run()
// fullyMountedApp deliberately mounts ONCE and shares the app across tests, so
// no single test may shut it down. It still holds a plugin child, so it is
// closed here — after the last test — for the same reason newTestApp cleans up.
if mountedTo != nil {
_ = mountedTo.Shutdown()
}
return code
}
func goTool() string {
if exe := filepath.Join(runtime.GOROOT(), "bin", "go"); exe != "" {
if _, err := os.Stat(exe); err == nil {
return exe
}
}
return "go"
}
// every subsystem the unified binary wires must appear in apps.Wire() — this
// is the proof Wire() actually assembles the whole matrix.
var wantSubsystems = []string{
@@ -55,6 +128,12 @@ func newTestApp(t *testing.T, enable ...string) *zip.App {
if err := cloud.MountAll(app, apps.Wire(), cfg, deps); err != nil {
t.Fatalf("MountAll(%v): %v", enable, err)
}
// Mounting a plugin subsystem starts a CHILD PROCESS, and zip stops it in an
// OnShutdown hook. A harness that mounts but never shuts down leaks that child
// for the life of the run — and because zip gives the child the host's stdout,
// the pipe stays open and `go test` blocks after the last test, then reports
// FAILURE on a suite that passed. Releasing what we acquired is the fix.
t.Cleanup(func() { _ = app.Shutdown() })
return app
}
+1 -1
View File
@@ -75,7 +75,7 @@ func main() {
}
// wireNames parses apps.go and returns the {Name: "..."} string literals from
// Wire()'s returned []MountSpec composite literal — the app names, in mount
// Wire()'s returned []AppSpec composite literal — the app names, in mount
// order. It is a light parse (no type-check), so it stays fast and depends only
// on the literal shape, which TestWireOrderMatchesFrozen already pins.
func wireNames(path string) []string {
+5 -5
View File
@@ -12,7 +12,7 @@
// the fused cloud control plane.
//
// Design — one mechanism, not many. The subsystem set is the explicit list
// apps.Wire() returns — []cloud.MountSpec in mount order (kms first-tier,
// apps.Wire() returns — []cloud.AppSpec in mount order (kms first-tier,
// iam 50, commerce 100, …, ai last), no init()-registry. A subcommand is just a
// *selection* over that slice:
//
@@ -142,7 +142,7 @@ func main() {
// isServeTarget reports whether sub names something this binary serves in-process —
// the full fused surface (cloud), standalone IAM, the datastore doc target, or any
// registered subsystem. Everything else is delegated to the Rust CLI (passthrough).
func isServeTarget(sub string, specs []cloud.MountSpec) bool {
func isServeTarget(sub string, specs []cloud.AppSpec) bool {
if _, ok := nonRegistrySubcommands[sub]; ok {
return true
}
@@ -150,7 +150,7 @@ func isServeTarget(sub string, specs []cloud.MountSpec) bool {
}
// dispatch routes a subcommand to its serve entrypoint.
func dispatch(sub string, specs []cloud.MountSpec) error {
func dispatch(sub string, specs []cloud.AppSpec) error {
switch sub {
case "cloud":
// Full fused surface: --enable governs the set (empty = all).
@@ -193,7 +193,7 @@ func dispatch(sub string, specs []cloud.MountSpec) error {
}
// registryHas reports whether name is a registered subsystem.
func registryHas(specs []cloud.MountSpec, name string) bool {
func registryHas(specs []cloud.AppSpec, name string) bool {
for _, spec := range specs {
if spec.Name == name {
return true
@@ -204,7 +204,7 @@ func registryHas(specs []cloud.MountSpec, name string) bool {
// usage prints the subcommand list: the non-registry targets (cloud, iam,
// datastore) plus every subsystem in the composition root (Wire()), sorted.
func usage(w *os.File, specs []cloud.MountSpec) {
func usage(w *os.File, specs []cloud.AppSpec) {
fmt.Fprintf(w, "hanzo %s — the unified Hanzo Go binary\n\n", version)
fmt.Fprintf(w, "Usage:\n hanzo <command> [flags]\n\n")
+1 -1
View File
@@ -59,7 +59,7 @@ func newCloudApp(t *testing.T) (*zip.App, string, cloud.Deps) {
app.Use(middleware.Recover())
app.Use(middleware.RequestID())
app.Use(middleware.Logger(deps.Logger))
if err := cloud.MountAll(app, []cloud.MountSpec{{Name: "kms", Mount: kms.Mount, OwnsHealth: true}}, cfg, deps); err != nil {
if err := cloud.MountAll(app, []cloud.AppSpec{{Name: "kms", Mount: kms.Mount, OwnsHealth: true}}, cfg, deps); err != nil {
t.Fatalf("MountAll: %v", err)
}
return app, dir, deps
+57 -7
View File
@@ -1,19 +1,69 @@
// o11y is the observability subsystem built as its OWN binary.
//
// It is an ordinary zip app. There is no SDK, no schema and nothing
// plugin-specific in here except zip.Addr — which is the whole plugin contract:
// serve on the socket a host handed us, or on our own port when run directly.
// The same binary therefore covers both deployments without a second code path.
//
// It mounts EXACTLY what apps.Wire() used to mount in-process, by calling the
// same o11y.MountO11y. The subsystem's code did not move and did not fork; only
// the process it runs in changed, which is the point — where a subsystem runs is
// a deployment decision, not a property of the source.
//
// This replaces the cmd/gen-app-cmds stub that used to live here. That stub
// called apps.ServeSingle("o11y"), which reaches apps.Wire() and therefore links
// EVERY subsystem — 4.3k packages and a ~500MB binary to serve one app. A plugin
// app is its own composition root, so it links only its own graph. The generator
// no longer writes this file: it emits a stub per {Name: "..."} literal in Wire(),
// and o11y's entry is now a cloud.PluginSpec call, so it is skipped by shape.
package main
import (
"fmt"
"os"
"github.com/hanzoai/cloud/apps"
"github.com/hanzoai/cloud"
"github.com/hanzoai/cloud/clients/o11y"
"github.com/zap-proto/zip"
)
// listenEnv names the address to serve on when this binary is run DIRECTLY
// rather than by a host. Under a host, zip.Addr ignores it and uses the private
// unix socket the host created. The default deliberately is not cloud's own
// :9653, so running both on one box does not collide.
const (
listenEnv = "O11Y_LISTEN"
defaultListen = ":9654"
)
// Standalone entry for the o11y app — generated by cmd/gen-app-cmds (the
// go:generate directive in apps/apps.go). do not hand-edit; the app is the one
// edit in apps.Wire(), this binary is regenerated. The same app also mounts into
// the unified cloud binary via apps.Wire().
func main() {
if err := apps.ServeSingle("o11y"); err != nil {
fmt.Fprintln(os.Stderr, err)
if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "o11y: %v\n", err)
os.Exit(1)
}
}
func run() error {
// The same config and the same Deps the unified binary builds — o11y reads
// only Logger and DataDir out of it, but building it the one canonical way
// keeps this entrypoint honest about what a subsystem may reach for.
deps := cloud.BuildDeps(cloud.LoadConfig())
app := zip.New(zip.Config{AppName: "o11y", Logger: deps.Logger})
if err := o11y.MountO11y(app, deps); err != nil {
return fmt.Errorf("mount: %w", err)
}
// Teardown belongs to the process that owns the resources. The OTLP
// collector, the trace sink and the event-ingest Datastore all live HERE
// now, so their flush-and-close runs here on our own shutdown rather than
// in the host's MountAll teardown.
app.OnShutdown(o11y.ShutdownO11y)
addr := os.Getenv(listenEnv)
if addr == "" {
addr = defaultListen
}
return app.Listen(zip.Addr(addr))
}
+3 -3
View File
@@ -1,6 +1,6 @@
module github.com/hanzoai/cloud
go 1.26.4
go 1.26.5
// Dependencies will be added as subsystems are mounted per HIP-0106.
@@ -43,7 +43,7 @@ require (
github.com/zap-proto/fiber/v3 v3.2.1
github.com/zap-proto/go v1.3.0
github.com/zap-proto/md v0.1.0
github.com/zap-proto/zip v1.10.0
github.com/zap-proto/zip v1.16.0
go.opentelemetry.io/collector/component v1.54.0
go.opentelemetry.io/collector/confmap v1.54.0
go.opentelemetry.io/collector/confmap/provider/envprovider v1.50.0
@@ -104,7 +104,7 @@ require (
github.com/sendgrid/sendgrid-go v3.16.1+incompatible // indirect
github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d // indirect
github.com/vultr/govultr/v3 v3.30.0 // indirect
github.com/zap-proto/http v0.3.0 // indirect
github.com/zap-proto/http v0.3.1 // indirect
github.com/zap-proto/zap2pb v0.2.0 // indirect
go.mongodb.org/mongo-driver v1.17.9 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
+4
View File
@@ -2103,12 +2103,16 @@ github.com/zap-proto/go v1.3.0 h1:S3rMoawwhH/BbSZ4G8zG05hJoQnMSMDPzIq75diCTqE=
github.com/zap-proto/go v1.3.0/go.mod h1:914SNGTH6Rv3Yu1MweWJBPEN8FZlo5C39QyhaB0C7Q0=
github.com/zap-proto/http v0.3.0 h1:l7DvlngiYqmzNY6fzyRYw2ZIAhF35FqwOe6mvAOqpMg=
github.com/zap-proto/http v0.3.0/go.mod h1:UYfGhDDCetgxs65XSev8Lpf65COg5vKQK+cWwZGh4zQ=
github.com/zap-proto/http v0.3.1 h1:A2rCPWYCX866eAsdiWuns0dvWnBmViZtGm4pwX7jwlY=
github.com/zap-proto/http v0.3.1/go.mod h1:UYfGhDDCetgxs65XSev8Lpf65COg5vKQK+cWwZGh4zQ=
github.com/zap-proto/md v0.1.0 h1:1R6w/i1FYAdGIIiOvNggKO0RjikzhWWRodQUOgzEEpc=
github.com/zap-proto/md v0.1.0/go.mod h1:pmMx2F4Dwj1H48PIuLzRZxB2R5qVvqeg8ZScKQIntyQ=
github.com/zap-proto/zap2pb v0.2.0 h1:sos6HnayhGMGLRO54px1InzimDzTZ2o5TSMEatYBjzs=
github.com/zap-proto/zap2pb v0.2.0/go.mod h1:wD97Z2VTPabDq/4AMNL++PWnQ0YwEtajiuNkLGg3/18=
github.com/zap-proto/zip v1.10.0 h1:0Swzr+SNr+4VeO8pUw+Umdkh3z/lnD0hJ4N1kObdP60=
github.com/zap-proto/zip v1.10.0/go.mod h1:9R3FOq2ItZa7G+9QilsB/punEpVSHtdXiQji0P84LSE=
github.com/zap-proto/zip v1.16.0 h1:Bb3StSa9xMHzWUOVDxeMUZFTrgRsBGyrm1tCJMvKRxw=
github.com/zap-proto/zip v1.16.0/go.mod h1:BxFNqjnAVhArMJ+s7VnXdwAtfOVy47GAi62wwqyu8go=
github.com/zeebo/assert v1.3.1 h1:vukIABvugfNMZMQO1ABsyQDJDTVQbn+LWSMy1ol1h6A=
github.com/zeebo/assert v1.3.1/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI=
+14 -6
View File
@@ -8,36 +8,44 @@ import (
"github.com/zap-proto/zip"
)
// PluginSpec returns a MountSpec that serves prefix from a SEPARATE binary
// PluginSpec returns a AppSpec that serves prefix from a SEPARATE binary
// instead of code linked into this one.
//
// It exists so that where a subsystem runs stops being a property of the source.
// zip.Load returns a zip.Service — the same type a linked-in service is — so the
// only difference between "compiled in" and "its own process" is which MountSpec
// only difference between "compiled in" and "its own process" is which AppSpec
// Wire() lists. Moving one out is a one-line edit at the composition root, and
// nothing downstream (routing, health, shutdown ordering) can tell the difference.
//
// Pass EVERY prefix the subsystem owns. o11y owns both /v1/o11y and /v1/sentry,
// and a prefix left out is not an error — it is a silent 404 on that subtree,
// which is the worst way for this to fail.
//
// The plugin names exactly one of Addr (already listening), Bin (the binary,
// normally go:embed'd) or Path. For Bin and Path, zip starts it as a child on a
// normally go:embed'd), Path, or URL+Sum (a release artifact, fetched and
// verified by digest). For Bin and Path, zip starts it as a child on a
// 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
// it a scoped Router would nest that prefix under the subsystem name and the
// routes would answer somewhere nobody is asking.
func PluginSpec(name, prefix string, p zip.Plugin) MountSpec {
func PluginSpec(name string, p zip.Plugin, prefixes ...string) AppSpec {
if p.Name == "" {
p.Name = name
}
return MountSpec{
return AppSpec{
Name: name,
Global: true,
// Deps are irrelevant to a plugin — it runs in its own process and
// receives nothing from this one — so this is zip.Load's Service with the
// router narrowed to the app it needs.
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(prefix, p)(app)
return zip.Load(p, prefixes...)(app)
},
}
}
+4 -4
View File
@@ -10,10 +10,10 @@ 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
// same AppSpec type, so Wire() can swap in-process for out-of-process by
// editing one line.
func TestPluginSpec_IsAnOrdinaryMountSpec(t *testing.T) {
s := PluginSpec("search", "/v1/search", zip.Plugin{Addr: "127.0.0.1:1"})
func TestPluginSpec_IsAnOrdinaryAppSpec(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)
}
@@ -28,7 +28,7 @@ func TestPluginSpec_IsAnOrdinaryMountSpec(t *testing.T) {
// 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", "/v1/bad", zip.Plugin{Addr: "127.0.0.1:1"})
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")
+2 -2
View File
@@ -128,7 +128,7 @@ 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 AppSpec, or Global: true if it really gates the whole binary",
s.name, strings.Join(*s.escaped, ", "), strings.Join(s.prefixes, ", "))
}
@@ -141,7 +141,7 @@ 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 fmt.Errorf("this subsystem takes the bare *zip.App; its AppSpec needs Global: true")
}
return fn(app, deps)
}
+7 -7
View File
@@ -36,7 +36,7 @@ func get(t *testing.T, app *zip.App, path string) int {
return resp.StatusCode
}
func mountAll(t *testing.T, app *zip.App, specs []cloud.MountSpec) error {
func mountAll(t *testing.T, app *zip.App, specs []cloud.AppSpec) error {
t.Helper()
enable := make([]string, 0, len(specs))
for _, s := range specs {
@@ -59,7 +59,7 @@ func newApp() *zip.App {
// the /v1/<name> convention every subsystem already follows.
func TestScopeConfinesUseToTheSubsystem(t *testing.T) {
app := newApp()
err := mountAll(t, app, []cloud.MountSpec{
err := mountAll(t, app, []cloud.AppSpec{
{Name: "guard", Mount: func(r cloud.Router, _ cloud.Deps) error {
r.Use(deny)
r.Get("/v1/guard/whoami", pong)
@@ -87,7 +87,7 @@ func TestScopeConfinesUseToTheSubsystem(t *testing.T) {
// it owns two subtrees and neither of them is /v1/iam alone.
func TestScopeHonoursDeclaredPrefixes(t *testing.T) {
app := newApp()
err := mountAll(t, app, []cloud.MountSpec{
err := mountAll(t, app, []cloud.AppSpec{
{Name: "identity", Prefixes: []string{"/v1/identity", "/login/oauth"},
Mount: func(r cloud.Router, _ cloud.Deps) error {
r.Use(deny)
@@ -120,7 +120,7 @@ func TestScopeHonoursDeclaredPrefixes(t *testing.T) {
// even in the failed attempt.
func TestScopeRefusesMiddlewareOutsideItsPrefixes(t *testing.T) {
app := newApp()
err := mountAll(t, app, []cloud.MountSpec{
err := mountAll(t, app, []cloud.AppSpec{
{Name: "neighbour", Mount: func(r cloud.Router, _ cloud.Deps) error {
r.Get("/v1/neighbour/ping", pong)
return nil
@@ -142,7 +142,7 @@ func TestScopeRefusesMiddlewareOutsideItsPrefixes(t *testing.T) {
// rate-limiting a leaf of its OWN subtree is the normal case and must pass.
func TestScopeAllowsGroupInsideItsPrefixes(t *testing.T) {
app := newApp()
err := mountAll(t, app, []cloud.MountSpec{
err := mountAll(t, app, []cloud.AppSpec{
{Name: "vault", Mount: func(r cloud.Router, _ cloud.Deps) error {
r.Group("/v1/vault/auth", deny)
r.Get("/v1/vault/auth/login", pong)
@@ -166,7 +166,7 @@ func TestScopeAllowsGroupInsideItsPrefixes(t *testing.T) {
// it has always meant. That is the capability, and it is spelled out in Wire().
func TestGlobalIsTheOnlyAppWideDoor(t *testing.T) {
app := newApp()
err := mountAll(t, app, []cloud.MountSpec{
err := mountAll(t, app, []cloud.AppSpec{
{Name: "edge", Global: true, Mount: cloud.Global(func(a *zip.App, _ cloud.Deps) error {
a.Use(deny)
return nil
@@ -189,7 +189,7 @@ func TestGlobalIsTheOnlyAppWideDoor(t *testing.T) {
// 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{
err := mountAll(t, app, []cloud.AppSpec{
{Name: "edge", Mount: cloud.Global(func(*zip.App, cloud.Deps) error { return nil })},
})
if err == nil {
+1 -1
View File
@@ -39,7 +39,7 @@ import (
// every enabled subsystem) before MountAll, runs the canonical middleware
// pipeline (Recover → RequestID → Logger), and shuts down gracefully on
// SIGINT/SIGTERM.
func Serve(specs []MountSpec, enable []string) error {
func Serve(specs []AppSpec, enable []string) error {
cfg := LoadConfig()
if enable != nil {
cfg.Enable = enable