Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18749656a2 | ||
|
|
7fcf19709a | ||
|
|
d3f91115c5 | ||
|
|
d699a0e297 | ||
|
|
be883e4659 | ||
|
|
73f66218b5 | ||
|
|
2f683d0625 |
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user