ingress is a subsystem, so it imports zip and nothing of its host

Mount took a cloud.Deps in order to read two fields out of it, Brand and
Domain, and handed both straight back on /_/ingress/config. Nothing else in
that struct was ever touched, and carrying it cost the whole dependency:
hanzoai/cloud ships two editions under one module path, so cloud.Deps is a
different type in each and a package that names it can be composed by exactly
one of them. This repo has already been on both sides of that. Days ago its
go.mod pinned cloud v1.801.293, where Deps has no Logger field and there is no
package-level Register at all; today it pins v0.1.1, where both exist. One file,
two truths, depending on which edition happened to be pinned. So ingress takes
the two strings now and cloud is out of go.mod.

The other half of the coupling was direction. An init() pushed ingress into
cloud.Registry, so the host had to be imported for the subsystem to announce
itself, and the graph closed on itself. App builds and returns its own *zip.App
instead: a host holds what it gets back and mounts it where it likes, and
ingress no longer knows a registry exists. The logger came out of the same
struct and is the process default now, which cannot be nil and so needs no
check.

None of this was ever compiled. It sat behind a `cloud` build tag that no make
target, no CI job and no Dockerfile passed, which is how a file gets to be
valid against one edition of its dependency and dead against the other without
anyone noticing. The tag protected nothing either -- cmd/ingress does not
import this package, and `go list -deps ./cmd/ingress` still shows no zip -- so
it is gone, and `.` joins test-unit and the hanzo.yml test list. A surface that
nothing builds is how this rotted the first time.
This commit is contained in:
2026-08-10 12:59:57 -07:00
parent ce76a09285
commit 2a40ec664c
10 changed files with 187 additions and 237 deletions
+23
View File
@@ -147,6 +147,29 @@ the intended behaviour: there is one name for each of these, and it is ours.
produces branding/group noise; hand-apply small generated-code changes and
extract only the intended CRD-schema block from a scratch run.
## In-process surface (`App`, root package)
`app.go` builds the small surface a co-resident binary needs — `GET
/_/ingress/healthz` and a read-only `GET /_/ingress/config` (brand, domain,
expected entrypoints/providers, ACME on/off, from `INGRESS_*` env). The proxy
itself is unaffected: it is `cmd/ingress`, out of process, at the cluster edge.
```go
func App(brand, domain string) (*zip.App, error)
```
Two strings, because those are the only two facts it reports back. It took
`cloud.Deps` and registered itself into `cloud.Registry`, which made ingress
import its own host — and `hanzoai/cloud` ships two editions under one module
path, so `cloud.Deps` is a different type in each and only one of them could
ever compose this package. Returning the app instead of writing into one handed
in means a host holds what it gets back and mounts it where it likes; nothing
here knows a registry exists. Rule: a subsystem imports `zip`, never its host.
It was also behind a `cloud` build tag, so nothing compiled it and it had
drifted to symbols cloud no longer has. No tag now, and `.` is in `test-unit`
and in the `hanzo.yml` test list.
## Static file serving (`staticFiles` middleware)
`pkg/middlewares/staticfiles` serves a site directly at the edge — the shared
static plane for the fleet (one ingress, unlimited sites). `Root` is a union:
+1 -1
View File
@@ -97,7 +97,7 @@ test: test-ui-unit test-unit test-integration
.PHONY: test-unit
#? test-unit: Run the unit tests
test-unit:
GOOS=$(GOOS) GOARCH=$(GOARCH) go test -cover "-coverprofile=cover.out" -v $(TESTFLAGS) ./pkg/... ./cmd/...
GOOS=$(GOOS) GOARCH=$(GOARCH) go test -cover "-coverprofile=cover.out" -v $(TESTFLAGS) . ./pkg/... ./cmd/...
.PHONY: test-integration
#? test-integration: Run the integration tests
+107
View File
@@ -0,0 +1,107 @@
// Copyright © 2026 Hanzo AI. MIT License.
// Package ingress is the in-process slice of Hanzo Ingress.
//
// The reverse-proxy / TLS / provider machinery lives in cmd/ingress and stays
// out-of-process at the cluster edge — nothing about that changes here. What
// this package builds is the small surface a co-resident binary needs: a health
// endpoint that answers while the edge proxy reloads, and a read-only
// /_/ingress/config view of the entrypoints and providers the deployment
// expects. That is enough to confirm an ingress is configured for the brand
// without standing the whole proxy up in the same process.
package ingress
import (
"net/http"
"os"
"strings"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
)
// App builds ingress's in-process surface and returns it, for a host to
// compose.
//
// Brand and domain are the only two facts ingress needs from whoever runs it,
// and both are read straight back out on /_/ingress/config, so they arrive as
// two strings. They used to arrive as a cloud.Deps — the host's own dependency
// struct, carried across the boundary to reach two fields. That made ingress
// import its host, which costs more than the import line: hanzoai/cloud ships
// two editions that both declare the module path github.com/hanzoai/cloud, so
// cloud.Deps names a different type in each, and a package that takes one can
// be composed by exactly one of them.
//
// Returning the app rather than writing into one handed in turns the last arrow
// around too. Ingress no longer registers itself in a registry the host owns —
// the host holds what it gets back and mounts it wherever it likes.
func App(brand, domain string) (*zip.App, error) {
app := zip.New(zip.Config{AppName: "ingress"})
cfg := configFromEnv()
app.Get("/_/ingress/healthz", func(c *zip.Ctx) error {
return c.JSON(http.StatusOK, map[string]any{
"status": "ok",
"service": "ingress",
})
})
app.Get("/_/ingress/config", func(c *zip.Ctx) error {
// Read-only view: brand, domain, expected entrypoints. No
// provider credentials, no TLS material, no upstream URLs.
return c.JSON(http.StatusOK, map[string]any{
"brand": brand,
"domain": domain,
"entrypoints": cfg.Entrypoints,
"providers": cfg.Providers,
"acme_enabled": cfg.ACMEEnabled,
})
})
luxlog.Default().Info("ingress mounted",
"brand", brand,
"domain", domain,
"entrypoints", strings.Join(cfg.Entrypoints, ","),
"providers", strings.Join(cfg.Providers, ","),
"acme_enabled", cfg.ACMEEnabled,
"json_variant", zip.JSONVariant,
)
return app, nil
}
// runtimeConfig is a tiny env-driven projection of the full Ingress
// static config. We do NOT import pkg/config/static into this build
// path — that pulls in the entire provider tree (Kubernetes, Docker,
// AWS, Consul, …). The full config still lives in cmd/ingress/ingress.go
// for the standalone binary.
type runtimeConfig struct {
Entrypoints []string
Providers []string
ACMEEnabled bool
}
func configFromEnv() runtimeConfig {
return runtimeConfig{
Entrypoints: splitCSV(getenv("INGRESS_ENTRYPOINTS", "web,websecure")),
Providers: splitCSV(getenv("INGRESS_PROVIDERS", "kubernetes")),
ACMEEnabled: getenv("INGRESS_ACME_ENABLED", "true") == "true",
}
}
func getenv(key, dflt string) string {
if v := os.Getenv(key); v != "" {
return v
}
return dflt
}
func splitCSV(s string) []string {
parts := strings.Split(s, ",")
out := parts[:0]
for _, p := range parts {
if t := strings.TrimSpace(p); t != "" {
out = append(out, t)
}
}
return out
}
+54
View File
@@ -0,0 +1,54 @@
// Copyright © 2026 Hanzo AI. MIT License.
package ingress
import (
"io"
"net/http/httptest"
"strings"
"testing"
)
func TestApp_ServesHealthAndConfig(t *testing.T) {
t.Setenv("INGRESS_ENTRYPOINTS", "web,websecure")
t.Setenv("INGRESS_PROVIDERS", "kubernetes,file")
t.Setenv("INGRESS_ACME_ENABLED", "true")
app, err := App("hanzo", "api.hanzo.ai")
if err != nil {
t.Fatalf("App: %v", err)
}
for _, tc := range []struct {
path string
want []string
}{
{"/_/ingress/healthz", []string{`"service":"ingress"`}},
{"/_/ingress/config", []string{
`"brand":"hanzo"`,
`"domain":"api.hanzo.ai"`,
`"entrypoints":["web","websecure"]`,
`"providers":["kubernetes","file"]`,
`"acme_enabled":true`,
}},
} {
t.Run(tc.path, func(t *testing.T) {
resp, err := app.Fiber().Test(httptest.NewRequest("GET", tc.path, nil))
if err != nil {
t.Fatalf("Test: %v", err)
}
if resp.StatusCode != 200 {
t.Fatalf("status: got %d want 200", resp.StatusCode)
}
raw, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
for _, want := range tc.want {
if !strings.Contains(string(raw), want) {
t.Fatalf("body: got %q, want substring %q", string(raw), want)
}
}
})
}
}
-18
View File
@@ -1,18 +0,0 @@
// Copyright © 2026 Hanzo AI. MIT License.
// Package ingress is the HIP-0106 unified-binary mount surface for
// the Hanzo Ingress subsystem.
//
// The mount logic ships under the `cloud` build tag so the standalone
// cmd/ingress binary (the full hanzo-ingress reverse proxy that runs
// at the k8s cluster edge) builds without pulling hanzoai/cloud +
// zap-proto/zip into its dependency tree. Build with `-tags=cloud` to
// compile the Mount.
//
// See pkg/ingress/mount.go for the canonical
//
// func Mount(app *zip.App, deps cloud.Deps) error
//
// signature and the init() callsite that registers ("ingress", order=90)
// in cloud.Registry.
package ingress
+1 -3
View File
@@ -2,10 +2,7 @@ module github.com/hanzoai/ingress
go 1.26.4
require github.com/hanzoai/cloud v0.1.1
require (
github.com/hanzoai/ha v0.2.0
github.com/BurntSushi/toml v1.6.0
github.com/Masterminds/sprig/v3 v3.2.3
github.com/abbot/go-http-auth v0.0.0-00010101000000-000000000000 // No tag on the repo.
@@ -29,6 +26,7 @@ require (
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
github.com/hanzoai/grpc-web v0.16.1
github.com/hanzoai/ha v0.2.0
github.com/hanzoai/ingress-parser v0.2.3
github.com/hanzoai/ingress/dynamic/ext v0.0.0-00010101000000-000000000000
github.com/hanzoai/yaegi v0.16.2
-2
View File
@@ -672,8 +672,6 @@ github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/hanzoai/cloud v0.1.1 h1:yVXuxz7o7sNnSA2+nuq+c0RnEA0Ic734S/r8/WTlLHk=
github.com/hanzoai/cloud v0.1.1/go.mod h1:4V7Uk80+9SI1uZTpCEMwub2STABVd9hD16mLQ4DY2uo=
github.com/hanzoai/grpc-web v0.16.1 h1:3ZR/caJNjv3mnmSCIkm0P562HTO8j+XoaUxtIGvoOjg=
github.com/hanzoai/grpc-web v0.16.1/go.mod h1:rNMbNyE1I/SpkKNuwjsaXBtKIUdA5zXqcSutSUN2xGc=
github.com/hanzoai/ha v0.2.0 h1:zgIbr3mjeuFfF26lkIKf/47ZM5kYBubWIPjEA24Yu7g=
+1 -1
View File
@@ -30,4 +30,4 @@ test:
- corepack enable && corepack prepare [email protected] --activate
- cd webui && pnpm install --frozen-lockfile && pnpm build
- go mod download
- go test ./pkg/provider/acme/... ./pkg/tls/...
- go test . ./pkg/provider/acme/... ./pkg/tls/...
-129
View File
@@ -1,129 +0,0 @@
// Copyright © 2026 Hanzo AI. MIT License.
//go:build cloud
// +build cloud
// Package ingress exposes the HIP-0106 unified-binary mount surface for
// Hanzo Ingress.
//
// Architecturally, the full reverse-proxy / TLS / provider machinery
// lives in `cmd/ingress` and stays out-of-process at the cluster edge —
// nothing about that changes. What Mount provides is the small
// in-process slice the unified cloud binary needs: a healthz endpoint
// (so probes work even when the edge proxy is reloading) and a
// read-only /_/ingress/config view of which providers + entrypoints
// the deployment expects. This lets cloud-mounted subsystems verify
// that an ingress is configured for the brand without standing the
// full proxy up inside the cloud binary.
package ingress
import (
"fmt"
"net/http"
"os"
"strings"
"github.com/hanzoai/cloud"
"github.com/zap-proto/zip"
)
// Mount registers ingress on app per HIP-0106. Order 90 — runs after
// gateway (80) so gateway's identity middleware is already installed
// when our health endpoint serves.
//
// The mount is intentionally LIGHT. Hanzo Ingress is k8s-native: a
// separate `hanzo-ingress` binary runs at the cluster edge with
// providers (Kubernetes, file, docker, etc.) discovering routes. The
// unified cloud binary co-resident with Ingress benefits from the
// /_/ingress/healthz endpoint for probe coverage and the
// /_/ingress/config endpoint to confirm the deployment's expected
// entrypoint set — nothing else here.
func Mount(app *zip.App, deps cloud.Deps) error {
if app == nil {
return fmt.Errorf("ingress.Mount: nil zip.App")
}
logger := deps.Logger
if logger == nil {
return fmt.Errorf("ingress.Mount: nil deps.Logger")
}
cfg := configFromEnv(deps)
app.Get("/_/ingress/healthz", func(c *zip.Ctx) error {
return c.JSON(http.StatusOK, map[string]any{
"status": "ok",
"service": "ingress",
})
})
app.Get("/_/ingress/config", func(c *zip.Ctx) error {
// Read-only view: brand, domain, expected entrypoints. No
// provider credentials, no TLS material, no upstream URLs.
return c.JSON(http.StatusOK, map[string]any{
"brand": deps.Brand,
"domain": deps.Domain,
"entrypoints": cfg.Entrypoints,
"providers": cfg.Providers,
"acme_enabled": cfg.ACMEEnabled,
})
})
logger.Info("ingress mounted",
"brand", deps.Brand,
"domain", deps.Domain,
"entrypoints", strings.Join(cfg.Entrypoints, ","),
"providers", strings.Join(cfg.Providers, ","),
"acme_enabled", cfg.ACMEEnabled,
"json_variant", zip.JSONVariant,
)
return nil
}
// runtimeConfig is a tiny env-driven projection of the full Ingress
// static config. We do NOT import pkg/config/static into the cloud
// build path — that pulls in the entire provider tree (Kubernetes,
// Docker, AWS, Consul, …). The full config still lives in
// cmd/ingress/ingress.go for the standalone binary.
type runtimeConfig struct {
Entrypoints []string
Providers []string
ACMEEnabled bool
}
func configFromEnv(deps cloud.Deps) runtimeConfig {
cfg := runtimeConfig{
Entrypoints: splitCSV(getenv("INGRESS_ENTRYPOINTS", "web,websecure")),
Providers: splitCSV(getenv("INGRESS_PROVIDERS", "kubernetes")),
ACMEEnabled: getenv("INGRESS_ACME_ENABLED", "true") == "true",
}
_ = deps
return cfg
}
func init() {
cloud.Register("ingress", 90, func(app any, deps cloud.Deps) error {
zapp, ok := app.(*zip.App)
if !ok {
return fmt.Errorf("ingress.Mount: expected *zip.App, got %T", app)
}
return Mount(zapp, deps)
})
}
func getenv(key, dflt string) string {
if v := os.Getenv(key); v != "" {
return v
}
return dflt
}
func splitCSV(s string) []string {
parts := strings.Split(s, ",")
out := parts[:0]
for _, p := range parts {
if t := strings.TrimSpace(p); t != "" {
out = append(out, t)
}
}
return out
}
-83
View File
@@ -1,83 +0,0 @@
// Copyright © 2026 Hanzo AI. MIT License.
//go:build cloud
// +build cloud
package ingress
import (
"io"
"net/http/httptest"
"strings"
"testing"
"github.com/hanzoai/cloud"
luxlog "github.com/luxfi/log"
"github.com/zap-proto/zip"
)
func TestMount_RegistersHealthAndConfig(t *testing.T) {
if !registryContains("ingress") {
t.Fatalf("cloud.Registry missing 'ingress'; Names=%v", registryNames())
}
t.Setenv("INGRESS_ENTRYPOINTS", "web,websecure")
t.Setenv("INGRESS_PROVIDERS", "kubernetes,file")
t.Setenv("INGRESS_ACME_ENABLED", "true")
app := zip.New(zip.Config{Logger: luxlog.New("test")})
deps := cloud.Deps{
Logger: luxlog.New("test"),
Brand: "hanzo",
Domain: "api.hanzo.ai",
DataDir: t.TempDir(),
}
if err := Mount(app, deps); err != nil {
t.Fatalf("Mount: %v", err)
}
for _, tc := range []struct {
path string
want string
status int
}{
{"/_/ingress/healthz", `"service":"ingress"`, 200},
{"/_/ingress/config", `"brand":"hanzo"`, 200},
} {
t.Run(tc.path, func(t *testing.T) {
req := httptest.NewRequest("GET", tc.path, nil)
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("Fiber Test: %v", err)
}
if resp.StatusCode != tc.status {
t.Fatalf("status: got %d want %d", resp.StatusCode, tc.status)
}
raw, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
if !strings.Contains(string(raw), tc.want) {
t.Fatalf("body: got %q, want substring %q", string(raw), tc.want)
}
})
}
}
func registryContains(name string) bool {
for _, s := range cloud.Registry {
if s.Name == name {
return true
}
}
return false
}
func registryNames() []string {
out := make([]string, 0, len(cloud.Registry))
for _, s := range cloud.Registry {
out = append(out, s.Name)
}
return out
}