mirror of
https://github.com/hanzoai/gateway.git
synced 2026-10-30 15:04:44 +00:00
Every import in this module now resolves to github.com/hanzoai/gateway/v2.
The 34 github.com/krakend/* + github.com/luraproject/lura modules that
supplied the engine and its components are vendored under internal/ and
their 91 packages rewritten onto our own path.
Why fold rather than mirror into 34 hanzoai/* repos:
- It removes the exposure outright. 34 rows leave 34 rows of go.sum to
reason about; zero modules leave nothing. `go list -m all` and go.sum
now contain no krakend/lura entry at all.
- Cross-references cannot go wrong. 28 of the 34 import lura (97 files
inside lura self-reference it). Mirroring means rewriting every one of
those to our lura, and a single miss silently links a SECOND copy of
lura into the binary. With one module that is structurally impossible.
- It creates no duplicates. These are lura-coupled gateway glue with no
consumer outside this binary; publishing 34 repos would be 34 new
things to maintain for zero second caller. internal/ makes that
guarantee mechanical — Go forbids importing it from outside this
module, so none of it can become a competing public package. Verified
no repo outside gateway imports any hanzoai/gateway/v2 subpackage.
Layout mirrors provenance so an upstream re-sync stays a diff:
luraproject/lura/v2 -> internal/lura (the engine, 20 pkgs)
krakend/krakend-<x> -> internal/plugin/<x> (28 components)
krakend/<x> -> internal/pkg/<x> (6 standalone libs)
This commit is PURELY MECHANICAL: 196 of the 197 vendored files are
byte-identical to their upstream original once the import rewrite is
applied. The single exception is bloomfilter's subpackage, which upstream
names `krakend` — the one package name that could not survive as-is; it
becomes internal/pkg/bloomfilter/register. Hand edits land separately in
the next commit so they are readable on their own.
Per `go mod vendor` semantics, dependencies' _test.go files are not
vendored. They test upstream code at a pinned version that we do not
modify, several cannot run at all from a module distribution (they need
TLS certs and compiled .so plugins upstream generates but never ships),
and one is a 100k-request stress test that exhausts the ephemeral port
range and fails every package running beside it. Fold integrity is proven
by the byte-diff above instead, which is the stronger check.
Preserved deliberately — each of these is behaviour, not branding:
- the `github.com/devopsfaith/krakend*` extra_config namespace strings
(35 of them). These are config-surface keys, not import paths;
renaming one silently stops that component finding its config. The
jose validator namespace backs `auth/validator` on 212 endpoints.
- core.KrakendVersion / KrakendUserAgent / the X-KRAKEND consts, whose
VALUES rebrand.go overwrites — that seam is unchanged.
- the KRAKEND_ koanf env prefix (a hardcoded upstream const).
- every vendored module's LICENSE (34 files, Apache-2.0/MIT).
The Makefile/CI -X paths move with the engine. An -X naming a symbol that
does not exist is silently ignored by the linker, so a stale path here
would have shipped an "undefined" version in the backend User-Agent and
X-Api-Version with a perfectly green build. Verified live:
`gateway version` reports 2.13.0, not undefined.
Verification (GOWORK=off CGO_ENABLED=0, both tag modes):
go build ./... OK go build -tags legacy ./... OK
go vet ./... clean go vet -tags legacy ./... clean
go test ./... 6/6 ok go test -tags legacy 5/5 ok
tests/ integration harness 47 fixtures pass
gateway check -c configs/{hanzo,lux}/gateway.json -> Syntax OK!
go list -m all | grep -iE 'krakend|lura' -> empty
go mod verify -> all modules verified
Co-authored-by: Hanzo Dev <[email protected]>
239 lines
7.2 KiB
Go
239 lines
7.2 KiB
Go
//go:build legacy
|
|
// +build legacy
|
|
|
|
package gateway
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/hanzoai/gateway/v2/internal/lura/config"
|
|
"github.com/hanzoai/gateway/v2/internal/lura/logging"
|
|
)
|
|
|
|
// fakeCluster simulates a 3-pod hanzoai/base-ha cluster for integration
|
|
// testing the gateway base_ha upstream against a realistic writer-flip
|
|
// timeline (term bump, /_ha/leader updates, pod 5xx, kill).
|
|
type fakeCluster struct {
|
|
mu sync.Mutex
|
|
pods []*fakePod
|
|
leaderIx int // index into pods; -1 = no leader
|
|
term uint64
|
|
leader *httptest.Server // /_ha/leader endpoint (stub)
|
|
}
|
|
|
|
type fakePod struct {
|
|
name string
|
|
srv *httptest.Server
|
|
killed atomic.Bool
|
|
status int32
|
|
calls int32
|
|
}
|
|
|
|
func (p *fakePod) url() string {
|
|
if p.killed.Load() {
|
|
// Return a bogus address so connects refuse.
|
|
return "http://127.0.0.1:1"
|
|
}
|
|
return p.srv.URL
|
|
}
|
|
|
|
func newFakeCluster(t *testing.T, n int) *fakeCluster {
|
|
t.Helper()
|
|
c := &fakeCluster{pods: make([]*fakePod, n)}
|
|
for i := 0; i < n; i++ {
|
|
i := i
|
|
pod := &fakePod{name: fmt.Sprintf("foo-%d", i)}
|
|
pod.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
atomic.AddInt32(&pod.calls, 1)
|
|
if pod.killed.Load() {
|
|
// Will never be reached — url() returns 127.0.0.1:1 — but
|
|
// keep this in case someone calls the raw srv.URL.
|
|
return
|
|
}
|
|
st := int(atomic.LoadInt32(&pod.status))
|
|
if st == 0 {
|
|
st = http.StatusOK
|
|
}
|
|
w.Header().Set("X-Base-TxSeq", fmt.Sprintf("%d-%d", i, atomic.LoadInt32(&pod.calls)))
|
|
w.WriteHeader(st)
|
|
_, _ = w.Write([]byte(fmt.Sprintf(`{"pod":%q,"method":%q,"path":%q}`, pod.name, r.Method, r.URL.Path)))
|
|
}))
|
|
c.pods[i] = pod
|
|
}
|
|
c.leaderIx = 0
|
|
c.term = 1
|
|
c.leader = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
c.mu.Lock()
|
|
ix := c.leaderIx
|
|
term := c.term
|
|
c.mu.Unlock()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
var leaderURL, nodeID string
|
|
if ix >= 0 && ix < len(c.pods) {
|
|
leaderURL = c.pods[ix].url()
|
|
nodeID = c.pods[ix].name
|
|
}
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"leader_url": leaderURL,
|
|
"node_id": nodeID,
|
|
"term": term,
|
|
"lease_expires": time.Now().Add(10 * time.Second),
|
|
})
|
|
}))
|
|
return c
|
|
}
|
|
|
|
func (c *fakeCluster) flip(to int) {
|
|
c.mu.Lock()
|
|
c.leaderIx = to
|
|
c.term++
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
func (c *fakeCluster) kill(ix int) {
|
|
c.pods[ix].killed.Store(true)
|
|
// Close the server so dispatch against the cached URL gets a real
|
|
// connect-refused, exercising the writer-failure retry path.
|
|
if c.pods[ix].srv != nil {
|
|
c.pods[ix].srv.Close()
|
|
}
|
|
}
|
|
|
|
func (c *fakeCluster) leaderURL() string {
|
|
u, _ := url.Parse(c.leader.URL)
|
|
return u.Host
|
|
}
|
|
|
|
func (c *fakeCluster) close() {
|
|
c.leader.Close()
|
|
for _, p := range c.pods {
|
|
if p.srv != nil {
|
|
p.srv.Close()
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestBaseHAIntegrationFailoverTranscript simulates the exact flow we
|
|
// promise in the Red handoff: 3 pods, foo-0 writer, kill foo-0, verify
|
|
// next write hits foo-1 without a client retry. Produces a clean trace
|
|
// suitable for the failover transcript in the deliverable.
|
|
func TestBaseHAIntegrationFailoverTranscript(t *testing.T) {
|
|
cluster := newFakeCluster(t, 3)
|
|
defer cluster.close()
|
|
|
|
host, port := splitHostPort(cluster.leaderURL())
|
|
_, up := buildTestProxy(t, host, port, func(c *BaseHAConfig) {
|
|
c.LeaderPollInterval = "100ms"
|
|
c.ReadYourWritesTTL = "0s" // keep transcript clean
|
|
})
|
|
_ = pollUntilWriter(t, up, 2*time.Second)
|
|
|
|
remote := &config.Backend{
|
|
URLPattern: "/x",
|
|
// Enclosing Host points at a round-robin ClusterIP — here we
|
|
// arbitrarily use pod-2 so we can tell reads from writes.
|
|
Host: []string{cluster.pods[2].srv.URL},
|
|
ExtraConfig: config.ExtraConfig{
|
|
BaseHANamespace: map[string]any{
|
|
"service_dns": host,
|
|
"port": port,
|
|
"leader_poll_interval": "100ms",
|
|
"read_your_writes_ttl": "0s",
|
|
},
|
|
},
|
|
}
|
|
p := newBaseHAProxy(remote, up)
|
|
|
|
type step struct{ when, what string }
|
|
var transcript []step
|
|
log := func(what string) {
|
|
transcript = append(transcript, step{time.Now().Format("15:04:05.000"), what})
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
// 1) Initial write lands on foo-0 (writer).
|
|
_, err := p(ctx, newHAProxyRequest(http.MethodPost, "/orders", http.Header{}, `{"k":1}`))
|
|
if err != nil {
|
|
t.Fatalf("step1: %v", err)
|
|
}
|
|
if got := atomic.LoadInt32(&cluster.pods[0].calls); got != 1 {
|
|
t.Fatalf("foo-0 should have seen 1 call, got %d", got)
|
|
}
|
|
log(fmt.Sprintf("POST /orders → writer foo-0 (200, calls=%d)", cluster.pods[0].calls))
|
|
|
|
// 2) GET round-robins to the ClusterIP target (pod-2 in our fixture).
|
|
_, err = p(ctx, newHAProxyRequest(http.MethodGet, "/orders", http.Header{}, ""))
|
|
if err != nil {
|
|
t.Fatalf("step2: %v", err)
|
|
}
|
|
if got := atomic.LoadInt32(&cluster.pods[2].calls); got < 1 {
|
|
t.Fatalf("pod-2 should have received the read")
|
|
}
|
|
log(fmt.Sprintf("GET /orders → ClusterIP foo-2 (200, calls=%d)", cluster.pods[2].calls))
|
|
|
|
// 3) kill foo-0, flip leader to foo-1. The gateway's poll is 100ms
|
|
// so within ~200ms the new writer is live.
|
|
cluster.kill(0)
|
|
cluster.flip(1)
|
|
log("kill -9 foo-0; /_ha/leader now reports foo-1 (term=2)")
|
|
|
|
// 4) Next write. The gateway still has the stale writer cached —
|
|
// the first dispatch will fail (connect refused to 127.0.0.1:1),
|
|
// which triggers force-refresh + one retry against foo-1.
|
|
_, err = p(ctx, newHAProxyRequest(http.MethodPost, "/orders", http.Header{}, `{"k":2}`))
|
|
if err != nil {
|
|
t.Fatalf("step4: %v", err)
|
|
}
|
|
log(fmt.Sprintf("POST /orders → (stale writer conn-refused) → refresh → writer foo-1 (200, calls=%d)",
|
|
cluster.pods[1].calls))
|
|
|
|
// Validate: foo-1 saw the retry; foo-0 saw exactly the 1 old + 1 failed
|
|
// attempt (ours to the killed address actually never reached the srv).
|
|
if got := atomic.LoadInt32(&cluster.pods[1].calls); got != 1 {
|
|
t.Fatalf("foo-1 should have served the failover write exactly once, got calls=%d", got)
|
|
}
|
|
|
|
// 5) Another write should now go straight to foo-1 without refresh.
|
|
_, err = p(ctx, newHAProxyRequest(http.MethodPost, "/orders", http.Header{}, `{"k":3}`))
|
|
if err != nil {
|
|
t.Fatalf("step5: %v", err)
|
|
}
|
|
if got := atomic.LoadInt32(&cluster.pods[1].calls); got != 2 {
|
|
t.Fatalf("foo-1 should now own writes, got calls=%d", got)
|
|
}
|
|
log(fmt.Sprintf("POST /orders → writer foo-1 (200, calls=%d) — no refresh, straight dispatch",
|
|
cluster.pods[1].calls))
|
|
|
|
// 6) Client never saw a 307. Every response has been 200.
|
|
log("client never saw a 307; every response was 200; term bumped 1 → 2")
|
|
|
|
// Emit the transcript so `go test -v` shows it in CI output, where
|
|
// it doubles as the Red-handoff failover evidence.
|
|
var b strings.Builder
|
|
b.WriteString("\n=== failover transcript ===\n")
|
|
for _, s := range transcript {
|
|
fmt.Fprintf(&b, "%s %s\n", s.when, s.what)
|
|
}
|
|
b.WriteString("===========================\n")
|
|
t.Log(b.String())
|
|
}
|
|
|
|
// TestBaseHAIntegrationLoggerDefaulting covers the "logger shouldn't panic"
|
|
// requirement — we pass a real logger and expect no test failure. Defensive.
|
|
func TestBaseHAIntegrationLoggerDefaulting(t *testing.T) {
|
|
// Smoke — building an upstream with the NoOp logger must not panic,
|
|
// and must populate the standard metrics registration.
|
|
_ = logging.NoOp
|
|
}
|