Files
gateway/base_ha_integration_test.go
zeekayandHanzo Dev 53350699b6 deps(vendor): fold the krakend/lura module set into internal/
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]>
2026-07-25 14:57:19 -07:00

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
}