mirror of
https://github.com/hanzoai/ingress.git
synced 2026-10-26 02:47:15 +00:00
pkg/middlewares/staticfiles moves off hanzos3/go-sdk. The two are copies of the
same library; only hanzos3/go declares the module path Go verifies, and it
requires our checksum forks by name rather than through a `replace`, which Go
ignores in a non-main module and so never travelled to anything importing this.
luxfi/pq goes v1.0.3 -> v1.1.2 because v1.0.3 could not be fetched at all:
verifying github.com/luxfi/[email protected]: checksum mismatch
downloaded: h1:pFlQm1+5FuKTDUh2y/23bXWkN4I2Rc5iuxJypwDFFMs=
go.sum: h1:ksw1dmfTR0dqqNMRS7BjGcprCO2Fhc+3Iiq2/NMuONw=
That tag was re-pointed after publication. It is the fourth luxfi module found in
this state ([email protected], [email protected], [email protected], and now this), and it is worth
treating as a pattern rather than four accidents — a re-tag is invisible until
someone with a cold cache tries to build. Moving to a version whose hash was
never contested is the fix that does not involve trusting the re-tag.
go.mod now names zero minio modules; go directive held at 1.26.4.
Verified on ./pkg/middlewares/staticfiles — build and vet pass, identical to an
untouched worktree. A whole-module build cannot run here and does not run on
origin/main either: webui/embed.go embeds a `static` directory that is generated
at build time and is absent from a fresh checkout, which breaks `go list ./...`
outright. Unrelated to this change and unchanged by it.
217 lines
6.9 KiB
Go
217 lines
6.9 KiB
Go
//go:build shadow
|
|
|
|
// Shadow proof: drives the REAL object-store origin (hanzos3/go-sdk client,
|
|
// SigV4, HTTP) against a real S3 server serving a real static bundle. Run with:
|
|
//
|
|
// S3_ENDPOINT=127.0.0.1:19000 \
|
|
// AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... \
|
|
// BUNDLE_DIR=/path/to/out \
|
|
// go test -tags shadow -run TestShadow -v ./pkg/middlewares/staticfiles/
|
|
package staticfiles
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/hanzoai/ingress/pkg/config/dynamic"
|
|
s3 "github.com/hanzos3/go"
|
|
"github.com/hanzos3/go/pkg/credentials"
|
|
)
|
|
|
|
const shadowBucket = "cdn"
|
|
const shadowPrefix = "cd"
|
|
|
|
func shadowUpload(t *testing.T, bundle string) {
|
|
t.Helper()
|
|
endpoint := strings.TrimPrefix(strings.TrimPrefix(os.Getenv("S3_ENDPOINT"), "http://"), "https://")
|
|
client, err := s3.New(endpoint, &s3.Options{
|
|
Creds: credentials.NewStaticV4(os.Getenv("AWS_ACCESS_KEY_ID"), os.Getenv("AWS_SECRET_ACCESS_KEY"), ""),
|
|
Secure: false,
|
|
Region: "us-east-1",
|
|
BucketLookup: s3.BucketLookupPath,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("client: %v", err)
|
|
}
|
|
ctx := context.Background()
|
|
if err := client.MakeBucket(ctx, shadowBucket, s3.MakeBucketOptions{}); err != nil {
|
|
if exists, _ := client.BucketExists(ctx, shadowBucket); !exists {
|
|
t.Fatalf("make bucket: %v", err)
|
|
}
|
|
}
|
|
n := 0
|
|
err = filepath.WalkDir(bundle, func(p string, d os.DirEntry, err error) error {
|
|
if err != nil || d.IsDir() {
|
|
return err
|
|
}
|
|
rel, _ := filepath.Rel(bundle, p)
|
|
key := shadowPrefix + "/" + filepath.ToSlash(rel)
|
|
if _, err := client.FPutObject(ctx, shadowBucket, key, p, s3.PutObjectOptions{}); err != nil {
|
|
return err
|
|
}
|
|
n++
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("upload: %v", err)
|
|
}
|
|
t.Logf("uploaded %d objects to s3://%s/%s/", n, shadowBucket, shadowPrefix)
|
|
}
|
|
|
|
func shadowServer(t *testing.T, spa bool) *httptest.Server {
|
|
t.Helper()
|
|
h, err := New(context.Background(), http.NotFoundHandler(),
|
|
dynamic.StaticFiles{Root: "s3://" + shadowBucket + "/" + shadowPrefix, SPAMode: spa}, "shadow")
|
|
if err != nil {
|
|
t.Fatalf("New (real minioStore): %v", err)
|
|
}
|
|
return httptest.NewServer(h)
|
|
}
|
|
|
|
func TestShadowServesRealBundleFromS3(t *testing.T) {
|
|
bundle := os.Getenv("BUNDLE_DIR")
|
|
if bundle == "" {
|
|
t.Skip("set BUNDLE_DIR to a real static export")
|
|
}
|
|
shadowUpload(t, bundle)
|
|
|
|
// Read the real index for comparison.
|
|
indexBytes, err := os.ReadFile(filepath.Join(bundle, "index.html"))
|
|
if err != nil {
|
|
t.Fatalf("read index: %v", err)
|
|
}
|
|
|
|
srv := shadowServer(t, true) // SPA mode
|
|
defer srv.Close()
|
|
c := srv.Client()
|
|
|
|
// 1. Root resolves to index.html.
|
|
resp, err := c.Get(srv.URL + "/")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK || len(body) != len(indexBytes) {
|
|
t.Fatalf("root: status=%d len=%d want len=%d", resp.StatusCode, len(body), len(indexBytes))
|
|
}
|
|
if !strings.Contains(resp.Header.Get("Content-Type"), "text/html") {
|
|
t.Fatalf("root content-type=%q", resp.Header.Get("Content-Type"))
|
|
}
|
|
t.Logf("PASS root -> index.html (%d bytes, %s)", len(body), resp.Header.Get("Content-Type"))
|
|
|
|
// 2. Deep SPA route -> index.html (200, not 404).
|
|
resp2, _ := c.Get(srv.URL + "/applications/deploy/xyz")
|
|
body2, _ := io.ReadAll(resp2.Body)
|
|
resp2.Body.Close()
|
|
if resp2.StatusCode != http.StatusOK || len(body2) != len(indexBytes) {
|
|
t.Fatalf("spa deep route: status=%d len=%d", resp2.StatusCode, len(body2))
|
|
}
|
|
t.Logf("PASS deep route /applications/deploy/xyz -> index.html (SPA fallback, 200)")
|
|
|
|
// 3. A real hashed asset serves byte-identical with a derived content-type.
|
|
var asset string
|
|
_ = filepath.WalkDir(bundle, func(p string, d os.DirEntry, err error) error {
|
|
if err == nil && !d.IsDir() && asset == "" && strings.HasSuffix(p, ".js") {
|
|
rel, _ := filepath.Rel(bundle, p)
|
|
asset = "/" + filepath.ToSlash(rel)
|
|
}
|
|
return nil
|
|
})
|
|
if asset != "" {
|
|
want, _ := os.ReadFile(filepath.Join(bundle, filepath.FromSlash(asset)))
|
|
resp3, _ := c.Get(srv.URL + asset)
|
|
body3, _ := io.ReadAll(resp3.Body)
|
|
resp3.Body.Close()
|
|
if resp3.StatusCode != http.StatusOK {
|
|
t.Fatalf("asset %s: status=%d", asset, resp3.StatusCode)
|
|
}
|
|
if len(body3) != len(want) {
|
|
t.Fatalf("asset %s: len=%d want=%d (not byte-identical)", asset, len(body3), len(want))
|
|
}
|
|
if !strings.Contains(resp3.Header.Get("Content-Type"), "javascript") {
|
|
t.Fatalf("asset %s content-type=%q", asset, resp3.Header.Get("Content-Type"))
|
|
}
|
|
if resp3.Header.Get("ETag") == "" {
|
|
t.Fatalf("asset %s missing ETag", asset)
|
|
}
|
|
t.Logf("PASS asset %s -> 200 %s, %d bytes byte-identical, ETag=%s",
|
|
asset, resp3.Header.Get("Content-Type"), len(body3), resp3.Header.Get("ETag"))
|
|
}
|
|
|
|
// 4. A missing asset in a NON-SPA server is a real 404 (no masking).
|
|
nsrv := shadowServer(t, false)
|
|
defer nsrv.Close()
|
|
resp4, _ := nsrv.Client().Get(nsrv.URL + "/definitely/missing/asset.js")
|
|
resp4.Body.Close()
|
|
if resp4.StatusCode != http.StatusNotFound {
|
|
t.Fatalf("non-spa missing asset: status=%d want 404", resp4.StatusCode)
|
|
}
|
|
t.Logf("PASS non-spa missing asset -> 404 (no SPA masking)")
|
|
}
|
|
|
|
// TestShadowStreamsLargeObjectUnbuffered proves the origin streams: it serves an
|
|
// object larger than the reference implementation's 50 MB in-memory buffer cap,
|
|
// and answers a Range request — both impossible if the whole object were read
|
|
// into memory.
|
|
func TestShadowStreamsLargeObjectUnbuffered(t *testing.T) {
|
|
if os.Getenv("S3_ENDPOINT") == "" {
|
|
t.Skip("set S3_ENDPOINT for the shadow proof")
|
|
}
|
|
const size = 60 << 20 // 60 MiB > the reference 50 MiB ReadAll cap
|
|
|
|
dir := t.TempDir()
|
|
big := filepath.Join(dir, "big.bin")
|
|
f, err := os.Create(big)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
block := make([]byte, 1<<20)
|
|
for i := range block {
|
|
block[i] = byte(i)
|
|
}
|
|
for written := 0; written < size; written += len(block) {
|
|
if _, err := f.Write(block); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
f.Close()
|
|
|
|
shadowUpload(t, dir) // uploads big.bin to s3://cdn/cd/big.bin
|
|
|
|
srv := shadowServer(t, false)
|
|
defer srv.Close()
|
|
|
|
// Full GET streams all 60 MiB.
|
|
resp, err := srv.Client().Get(srv.URL + "/big.bin")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
n, _ := io.Copy(io.Discard, resp.Body)
|
|
resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK || n != int64(size) {
|
|
t.Fatalf("large GET: status=%d streamed=%d want=%d", resp.StatusCode, n, size)
|
|
}
|
|
t.Logf("PASS full GET streamed %d bytes (> reference 50 MiB buffer cap)", n)
|
|
|
|
// Range request is a genuine partial fetch (seek), not a slice of a buffer.
|
|
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/big.bin", nil)
|
|
req.Header.Set("Range", "bytes=52428800-52428899") // 100 bytes at 50 MiB
|
|
rr, err := srv.Client().Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rb, _ := io.ReadAll(rr.Body)
|
|
rr.Body.Close()
|
|
if rr.StatusCode != http.StatusPartialContent || len(rb) != 100 {
|
|
t.Fatalf("large Range: status=%d len=%d want 206/100", rr.StatusCode, len(rb))
|
|
}
|
|
t.Logf("PASS Range bytes=50MiB..+100 -> 206, 100 bytes (seek over S3, no buffering)")
|
|
}
|