feat: support apk
This commit is contained in:
345
tests/integration/apk_proxy_test.go
Normal file
345
tests/integration/apk_proxy_test.go
Normal file
@@ -0,0 +1,345 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/any-hub/any-hub/internal/cache"
|
||||
"github.com/any-hub/any-hub/internal/config"
|
||||
"github.com/any-hub/any-hub/internal/proxy"
|
||||
"github.com/any-hub/any-hub/internal/server"
|
||||
)
|
||||
|
||||
func TestAPKProxyCachesIndexAndPackages(t *testing.T) {
|
||||
stub := newAPKStub(t)
|
||||
defer stub.Close()
|
||||
|
||||
storageDir := t.TempDir()
|
||||
cfg := &config.Config{
|
||||
Global: config.GlobalConfig{
|
||||
ListenPort: 5400,
|
||||
CacheTTL: config.Duration(time.Hour),
|
||||
StoragePath: storageDir,
|
||||
},
|
||||
Hubs: []config.HubConfig{
|
||||
{
|
||||
Name: "apk",
|
||||
Domain: "apk.hub.local",
|
||||
Type: "apk",
|
||||
Module: "apk",
|
||||
Upstream: stub.URL,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
registry, err := server.NewHubRegistry(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("registry error: %v", err)
|
||||
}
|
||||
|
||||
logger := logrus.New()
|
||||
logger.SetOutput(io.Discard)
|
||||
|
||||
store, err := cache.NewStore(storageDir)
|
||||
if err != nil {
|
||||
t.Fatalf("store error: %v", err)
|
||||
}
|
||||
|
||||
app, err := server.NewApp(server.AppOptions{
|
||||
Logger: logger,
|
||||
Registry: registry,
|
||||
Proxy: proxy.NewHandler(server.NewUpstreamClient(cfg), logger, store),
|
||||
ListenPort: 5400,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("app error: %v", err)
|
||||
}
|
||||
|
||||
doRequest := func(p string) *http.Response {
|
||||
req := httptest.NewRequest(http.MethodGet, "http://apk.hub.local"+p, nil)
|
||||
req.Host = "apk.hub.local"
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test error: %v", err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
resp := doRequest(stub.indexPath)
|
||||
if resp.StatusCode != fiber.StatusOK {
|
||||
t.Fatalf("expected 200 for index, got %d", resp.StatusCode)
|
||||
}
|
||||
if hit := resp.Header.Get("X-Any-Hub-Cache-Hit"); hit != "false" {
|
||||
t.Fatalf("expected cache miss on first index fetch, got %s", hit)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if !bytes.Equal(body, stub.indexBody) {
|
||||
t.Fatalf("index body mismatch on first fetch: %d bytes", len(body))
|
||||
}
|
||||
|
||||
resp2 := doRequest(stub.indexPath)
|
||||
if resp2.StatusCode != fiber.StatusOK {
|
||||
t.Fatalf("expected 200 for cached index, got %d", resp2.StatusCode)
|
||||
}
|
||||
if hit := resp2.Header.Get("X-Any-Hub-Cache-Hit"); hit != "true" {
|
||||
t.Fatalf("expected cache hit for index, got %s", hit)
|
||||
}
|
||||
body2, _ := io.ReadAll(resp2.Body)
|
||||
resp2.Body.Close()
|
||||
if !bytes.Equal(body2, stub.indexBody) {
|
||||
t.Fatalf("index body mismatch on cache hit: %d bytes", len(body2))
|
||||
}
|
||||
|
||||
sigResp := doRequest(stub.signaturePath)
|
||||
if sigResp.StatusCode != fiber.StatusOK {
|
||||
t.Fatalf("expected 200 for signature, got %d", sigResp.StatusCode)
|
||||
}
|
||||
if hit := sigResp.Header.Get("X-Any-Hub-Cache-Hit"); hit != "false" {
|
||||
t.Fatalf("expected cache miss on first signature fetch, got %s", hit)
|
||||
}
|
||||
_, _ = io.ReadAll(sigResp.Body)
|
||||
sigResp.Body.Close()
|
||||
|
||||
sigResp2 := doRequest(stub.signaturePath)
|
||||
if sigResp2.StatusCode != fiber.StatusOK {
|
||||
t.Fatalf("expected 200 for cached signature, got %d", sigResp2.StatusCode)
|
||||
}
|
||||
if hit := sigResp2.Header.Get("X-Any-Hub-Cache-Hit"); hit != "true" {
|
||||
t.Fatalf("expected cache hit for signature, got %s", hit)
|
||||
}
|
||||
sigResp2.Body.Close()
|
||||
|
||||
pkgResp := doRequest(stub.packagePath)
|
||||
if pkgResp.StatusCode != fiber.StatusOK {
|
||||
t.Fatalf("expected 200 for package, got %d", pkgResp.StatusCode)
|
||||
}
|
||||
if hit := pkgResp.Header.Get("X-Any-Hub-Cache-Hit"); hit != "false" {
|
||||
t.Fatalf("expected cache miss on first package fetch, got %s", hit)
|
||||
}
|
||||
pkgBody, _ := io.ReadAll(pkgResp.Body)
|
||||
pkgResp.Body.Close()
|
||||
if !bytes.Equal(pkgBody, stub.packageBody) {
|
||||
t.Fatalf("package body mismatch on first fetch: %d bytes", len(pkgBody))
|
||||
}
|
||||
|
||||
pkgResp2 := doRequest(stub.packagePath)
|
||||
if pkgResp2.StatusCode != fiber.StatusOK {
|
||||
t.Fatalf("expected 200 for cached package, got %d", pkgResp2.StatusCode)
|
||||
}
|
||||
if hit := pkgResp2.Header.Get("X-Any-Hub-Cache-Hit"); hit != "true" {
|
||||
t.Fatalf("expected cache hit for package, got %s", hit)
|
||||
}
|
||||
pkgBody2, _ := io.ReadAll(pkgResp2.Body)
|
||||
pkgResp2.Body.Close()
|
||||
if !bytes.Equal(pkgBody2, stub.packageBody) {
|
||||
t.Fatalf("package body mismatch on cache hit: %d bytes", len(pkgBody2))
|
||||
}
|
||||
|
||||
if stub.IndexGets() != 1 {
|
||||
t.Fatalf("expected single index GET, got %d", stub.IndexGets())
|
||||
}
|
||||
if stub.IndexHeads() != 1 {
|
||||
t.Fatalf("expected single index HEAD revalidate, got %d", stub.IndexHeads())
|
||||
}
|
||||
if stub.SignatureGets() != 1 {
|
||||
t.Fatalf("expected single signature GET, got %d", stub.SignatureGets())
|
||||
}
|
||||
if stub.SignatureHeads() != 1 {
|
||||
t.Fatalf("expected single signature HEAD revalidate, got %d", stub.SignatureHeads())
|
||||
}
|
||||
if stub.PackageGets() != 1 {
|
||||
t.Fatalf("expected single package GET, got %d", stub.PackageGets())
|
||||
}
|
||||
if stub.PackageHeads() != 0 {
|
||||
t.Fatalf("expected zero package HEAD revalidate, got %d", stub.PackageHeads())
|
||||
}
|
||||
|
||||
verifyAPKStored(t, storageDir, "apk", stub.indexPath, int64(len(stub.indexBody)))
|
||||
verifyAPKStored(t, storageDir, "apk", stub.signaturePath, int64(len(stub.signatureBody)))
|
||||
verifyAPKStored(t, storageDir, "apk", stub.packagePath, int64(len(stub.packageBody)))
|
||||
}
|
||||
|
||||
func verifyAPKStored(t *testing.T, basePath, hubName, locatorPath string, expectedSize int64) {
|
||||
t.Helper()
|
||||
clean := path.Clean("/" + locatorPath)
|
||||
clean = strings.TrimPrefix(clean, "/")
|
||||
fullPath := filepath.Join(basePath, hubName, clean)
|
||||
info, err := os.Stat(fullPath)
|
||||
if err != nil {
|
||||
t.Fatalf("expected cached file at %s: %v", fullPath, err)
|
||||
}
|
||||
if info.Size() != expectedSize {
|
||||
t.Fatalf("cached file %s size mismatch: got %d want %d", fullPath, info.Size(), expectedSize)
|
||||
}
|
||||
}
|
||||
|
||||
type apkStub struct {
|
||||
server *http.Server
|
||||
listener net.Listener
|
||||
URL string
|
||||
|
||||
mu sync.Mutex
|
||||
indexPath string
|
||||
signaturePath string
|
||||
packagePath string
|
||||
indexBody []byte
|
||||
signatureBody []byte
|
||||
packageBody []byte
|
||||
indexGets int
|
||||
indexHeads int
|
||||
signatureGets int
|
||||
signatureHeads int
|
||||
packageGets int
|
||||
packageHeads int
|
||||
}
|
||||
|
||||
func newAPKStub(t *testing.T) *apkStub {
|
||||
t.Helper()
|
||||
stub := &apkStub{
|
||||
indexPath: "/v3.19/main/x86_64/APKINDEX.tar.gz",
|
||||
signaturePath: "/v3.19/main/x86_64/APKINDEX.tar.gz.asc",
|
||||
packagePath: "/v3.22/community/x86_64/tini-static-0.19.0-r3.apk",
|
||||
indexBody: []byte("apk-index-body"),
|
||||
signatureBody: []byte("apk-index-signature"),
|
||||
packageBody: bytes.Repeat([]byte("apk-payload-"), 64*1024),
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc(stub.indexPath, stub.handleIndex)
|
||||
mux.HandleFunc(stub.signaturePath, stub.handleSignature)
|
||||
mux.HandleFunc(stub.packagePath, stub.handlePackage)
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("unable to start apk stub: %v", err)
|
||||
}
|
||||
|
||||
srv := &http.Server{Handler: mux}
|
||||
stub.server = srv
|
||||
stub.listener = listener
|
||||
stub.URL = "http://" + listener.Addr().String()
|
||||
|
||||
go func() {
|
||||
_ = srv.Serve(listener)
|
||||
}()
|
||||
return stub
|
||||
}
|
||||
|
||||
func (s *apkStub) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
s.handleWithETag(w, r, &s.indexGets, &s.indexHeads, s.indexBody, "application/gzip")
|
||||
}
|
||||
|
||||
func (s *apkStub) handleSignature(w http.ResponseWriter, r *http.Request) {
|
||||
s.handleWithETag(w, r, &s.signatureGets, &s.signatureHeads, s.signatureBody, "application/pgp-signature")
|
||||
}
|
||||
|
||||
func (s *apkStub) handlePackage(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if r.Method == http.MethodHead {
|
||||
s.packageHeads++
|
||||
w.Header().Set("Content-Type", "application/vnd.android.package-archive")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
s.packageGets++
|
||||
w.Header().Set("Content-Type", "application/vnd.android.package-archive")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(s.packageBody)))
|
||||
_, _ = w.Write(s.packageBody)
|
||||
}
|
||||
|
||||
func (s *apkStub) handleWithETag(w http.ResponseWriter, r *http.Request, gets, heads *int, body []byte, contentType string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
etag := "\"apk-etag\""
|
||||
if r.Method == http.MethodHead {
|
||||
*heads++
|
||||
w.Header().Set("ETag", etag)
|
||||
w.Header().Set("Last-Modified", time.Now().UTC().Format(http.TimeFormat))
|
||||
if matchETag(r, strings.Trim(etag, `"`)) {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
if contentType != "" {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
*gets++
|
||||
w.Header().Set("ETag", etag)
|
||||
w.Header().Set("Last-Modified", time.Now().UTC().Format(http.TimeFormat))
|
||||
if contentType != "" {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
}
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
func (s *apkStub) IndexGets() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.indexGets
|
||||
}
|
||||
|
||||
func (s *apkStub) IndexHeads() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.indexHeads
|
||||
}
|
||||
|
||||
func (s *apkStub) SignatureGets() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.signatureGets
|
||||
}
|
||||
|
||||
func (s *apkStub) SignatureHeads() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.signatureHeads
|
||||
}
|
||||
|
||||
func (s *apkStub) PackageGets() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.packageGets
|
||||
}
|
||||
|
||||
func (s *apkStub) PackageHeads() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.packageHeads
|
||||
}
|
||||
|
||||
func (s *apkStub) Close() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
if s.server != nil {
|
||||
_ = s.server.Shutdown(ctx)
|
||||
}
|
||||
if s.listener != nil {
|
||||
_ = s.listener.Close()
|
||||
}
|
||||
}
|
||||
277
tests/integration/apt_package_proxy_test.go
Normal file
277
tests/integration/apt_package_proxy_test.go
Normal file
@@ -0,0 +1,277 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/any-hub/any-hub/internal/cache"
|
||||
"github.com/any-hub/any-hub/internal/config"
|
||||
"github.com/any-hub/any-hub/internal/proxy"
|
||||
"github.com/any-hub/any-hub/internal/server"
|
||||
)
|
||||
|
||||
func TestAptPackagesCachedWithoutRevalidate(t *testing.T) {
|
||||
stub := newAptPackageStub(t)
|
||||
defer stub.Close()
|
||||
|
||||
storageDir := t.TempDir()
|
||||
cfg := &config.Config{
|
||||
Global: config.GlobalConfig{
|
||||
ListenPort: 5000,
|
||||
CacheTTL: config.Duration(time.Hour),
|
||||
StoragePath: storageDir,
|
||||
},
|
||||
Hubs: []config.HubConfig{
|
||||
{
|
||||
Name: "apt",
|
||||
Domain: "apt.hub.local",
|
||||
Type: "debian",
|
||||
Module: "debian",
|
||||
Upstream: stub.URL,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
registry, err := server.NewHubRegistry(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("registry error: %v", err)
|
||||
}
|
||||
|
||||
logger := logrus.New()
|
||||
logger.SetOutput(io.Discard)
|
||||
|
||||
store, err := cache.NewStore(storageDir)
|
||||
if err != nil {
|
||||
t.Fatalf("store error: %v", err)
|
||||
}
|
||||
|
||||
app, err := server.NewApp(server.AppOptions{
|
||||
Logger: logger,
|
||||
Registry: registry,
|
||||
Proxy: proxy.NewHandler(server.NewUpstreamClient(cfg), logger, store),
|
||||
ListenPort: 5000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("app error: %v", err)
|
||||
}
|
||||
|
||||
doRequest := func(p string) *http.Response {
|
||||
req := httptest.NewRequest(http.MethodGet, "http://apt.hub.local"+p, nil)
|
||||
req.Host = "apt.hub.local"
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test error: %v", err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
resp := doRequest(stub.packagePath)
|
||||
if resp.StatusCode != fiber.StatusOK {
|
||||
t.Fatalf("expected 200 for package, got %d", resp.StatusCode)
|
||||
}
|
||||
if hit := resp.Header.Get("X-Any-Hub-Cache-Hit"); hit != "false" {
|
||||
t.Fatalf("expected cache miss on first package fetch, got %s", hit)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if !bytes.Equal(body, stub.packageBody) {
|
||||
t.Fatalf("package body mismatch on first fetch: %d bytes", len(body))
|
||||
}
|
||||
|
||||
resp2 := doRequest(stub.packagePath)
|
||||
if resp2.StatusCode != fiber.StatusOK {
|
||||
t.Fatalf("expected 200 for cached package, got %d", resp2.StatusCode)
|
||||
}
|
||||
if hit := resp2.Header.Get("X-Any-Hub-Cache-Hit"); hit != "true" {
|
||||
t.Fatalf("expected cache hit for package, got %s", hit)
|
||||
}
|
||||
body2, _ := io.ReadAll(resp2.Body)
|
||||
resp2.Body.Close()
|
||||
if !bytes.Equal(body2, stub.packageBody) {
|
||||
t.Fatalf("package body mismatch on cache hit: %d bytes", len(body2))
|
||||
}
|
||||
|
||||
hashResp := doRequest(stub.byHashPath)
|
||||
if hashResp.StatusCode != fiber.StatusOK {
|
||||
t.Fatalf("expected 200 for by-hash, got %d", hashResp.StatusCode)
|
||||
}
|
||||
if hit := hashResp.Header.Get("X-Any-Hub-Cache-Hit"); hit != "false" {
|
||||
t.Fatalf("expected cache miss on first by-hash fetch, got %s", hit)
|
||||
}
|
||||
hashBody, _ := io.ReadAll(hashResp.Body)
|
||||
hashResp.Body.Close()
|
||||
if !bytes.Equal(hashBody, stub.byHashBody) {
|
||||
t.Fatalf("by-hash body mismatch on first fetch: %d bytes", len(hashBody))
|
||||
}
|
||||
|
||||
hashResp2 := doRequest(stub.byHashPath)
|
||||
if hashResp2.StatusCode != fiber.StatusOK {
|
||||
t.Fatalf("expected 200 for cached by-hash, got %d", hashResp2.StatusCode)
|
||||
}
|
||||
if hit := hashResp2.Header.Get("X-Any-Hub-Cache-Hit"); hit != "true" {
|
||||
t.Fatalf("expected cache hit for by-hash, got %s", hit)
|
||||
}
|
||||
hashBody2, _ := io.ReadAll(hashResp2.Body)
|
||||
hashResp2.Body.Close()
|
||||
if !bytes.Equal(hashBody2, stub.byHashBody) {
|
||||
t.Fatalf("by-hash body mismatch on cache hit: %d bytes", len(hashBody2))
|
||||
}
|
||||
|
||||
if stub.PackageGets() != 1 {
|
||||
t.Fatalf("expected single package GET, got %d", stub.PackageGets())
|
||||
}
|
||||
if stub.PackageHeads() != 0 {
|
||||
t.Fatalf("expected zero package HEAD revalidate, got %d", stub.PackageHeads())
|
||||
}
|
||||
if stub.ByHashGets() != 1 {
|
||||
t.Fatalf("expected single by-hash GET, got %d", stub.ByHashGets())
|
||||
}
|
||||
if stub.ByHashHeads() != 0 {
|
||||
t.Fatalf("expected zero by-hash HEAD revalidate, got %d", stub.ByHashHeads())
|
||||
}
|
||||
|
||||
verifyStoredFile(t, storageDir, "apt", stub.packagePath, int64(len(stub.packageBody)))
|
||||
verifyStoredFile(t, storageDir, "apt", stub.byHashPath, int64(len(stub.byHashBody)))
|
||||
}
|
||||
|
||||
func verifyStoredFile(t *testing.T, basePath, hubName, locatorPath string, expectedSize int64) {
|
||||
t.Helper()
|
||||
clean := path.Clean("/" + locatorPath)
|
||||
clean = strings.TrimPrefix(clean, "/")
|
||||
fullPath := filepath.Join(basePath, hubName, clean)
|
||||
info, err := os.Stat(fullPath)
|
||||
if err != nil {
|
||||
t.Fatalf("expected cached file at %s: %v", fullPath, err)
|
||||
}
|
||||
if info.Size() != expectedSize {
|
||||
t.Fatalf("cached file %s size mismatch: got %d want %d", fullPath, info.Size(), expectedSize)
|
||||
}
|
||||
}
|
||||
|
||||
type aptPackageStub struct {
|
||||
server *http.Server
|
||||
listener net.Listener
|
||||
URL string
|
||||
|
||||
mu sync.Mutex
|
||||
packagePath string
|
||||
byHashPath string
|
||||
packageBody []byte
|
||||
byHashBody []byte
|
||||
packageGets int
|
||||
packageHeads int
|
||||
byHashGets int
|
||||
byHashHeads int
|
||||
}
|
||||
|
||||
func newAptPackageStub(t *testing.T) *aptPackageStub {
|
||||
t.Helper()
|
||||
|
||||
stub := &aptPackageStub{
|
||||
packagePath: "/pool/main/h/hello_1.0_amd64.deb",
|
||||
byHashPath: "/dists/bookworm/by-hash/sha256/deadbeef",
|
||||
packageBody: bytes.Repeat([]byte("deb-payload-"), 128*1024),
|
||||
byHashBody: []byte("hash-index-body"),
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc(stub.packagePath, stub.handlePackage)
|
||||
mux.HandleFunc(stub.byHashPath, stub.handleByHash)
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("unable to start apt package stub: %v", err)
|
||||
}
|
||||
|
||||
srv := &http.Server{Handler: mux}
|
||||
stub.server = srv
|
||||
stub.listener = listener
|
||||
stub.URL = "http://" + listener.Addr().String()
|
||||
|
||||
go func() {
|
||||
_ = srv.Serve(listener)
|
||||
}()
|
||||
return stub
|
||||
}
|
||||
|
||||
func (s *aptPackageStub) handlePackage(w http.ResponseWriter, r *http.Request) {
|
||||
s.handleImmutable(w, r, &s.packageGets, &s.packageHeads, s.packageBody, "application/vnd.debian.binary-package")
|
||||
}
|
||||
|
||||
func (s *aptPackageStub) handleByHash(w http.ResponseWriter, r *http.Request) {
|
||||
s.handleImmutable(w, r, &s.byHashGets, &s.byHashHeads, s.byHashBody, "text/plain")
|
||||
}
|
||||
|
||||
func (s *aptPackageStub) handleImmutable(w http.ResponseWriter, r *http.Request, gets, heads *int, body []byte, contentType string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if r.Method == http.MethodHead {
|
||||
*heads++
|
||||
if contentType != "" {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
*gets++
|
||||
if contentType != "" {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
}
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
func (s *aptPackageStub) PackageGets() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.packageGets
|
||||
}
|
||||
|
||||
func (s *aptPackageStub) PackageHeads() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.packageHeads
|
||||
}
|
||||
|
||||
func (s *aptPackageStub) ByHashGets() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.byHashGets
|
||||
}
|
||||
|
||||
func (s *aptPackageStub) ByHashHeads() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.byHashHeads
|
||||
}
|
||||
|
||||
func (s *aptPackageStub) Close() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
if s.server != nil {
|
||||
_ = s.server.Shutdown(ctx)
|
||||
}
|
||||
if s.listener != nil {
|
||||
_ = s.listener.Close()
|
||||
}
|
||||
}
|
||||
266
tests/integration/apt_update_proxy_test.go
Normal file
266
tests/integration/apt_update_proxy_test.go
Normal file
@@ -0,0 +1,266 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/any-hub/any-hub/internal/cache"
|
||||
"github.com/any-hub/any-hub/internal/config"
|
||||
"github.com/any-hub/any-hub/internal/proxy"
|
||||
"github.com/any-hub/any-hub/internal/server"
|
||||
)
|
||||
|
||||
func TestAptUpdateCachesIndexes(t *testing.T) {
|
||||
stub := newAptStub(t)
|
||||
defer stub.Close()
|
||||
|
||||
storageDir := t.TempDir()
|
||||
cfg := &config.Config{
|
||||
Global: config.GlobalConfig{
|
||||
ListenPort: 5000,
|
||||
CacheTTL: config.Duration(time.Hour),
|
||||
StoragePath: storageDir,
|
||||
},
|
||||
Hubs: []config.HubConfig{
|
||||
{
|
||||
Name: "apt",
|
||||
Domain: "apt.hub.local",
|
||||
Type: "debian",
|
||||
Module: "debian",
|
||||
Upstream: stub.URL,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
registry, err := server.NewHubRegistry(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("registry error: %v", err)
|
||||
}
|
||||
|
||||
logger := logrus.New()
|
||||
logger.SetOutput(io.Discard)
|
||||
|
||||
store, err := cache.NewStore(storageDir)
|
||||
if err != nil {
|
||||
t.Fatalf("store error: %v", err)
|
||||
}
|
||||
|
||||
app, err := server.NewApp(server.AppOptions{
|
||||
Logger: logger,
|
||||
Registry: registry,
|
||||
Proxy: proxy.NewHandler(server.NewUpstreamClient(cfg), logger, store),
|
||||
ListenPort: 5000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("app error: %v", err)
|
||||
}
|
||||
|
||||
doRequest := func(path string) *http.Response {
|
||||
req := httptest.NewRequest("GET", "http://apt.hub.local"+path, nil)
|
||||
req.Host = "apt.hub.local"
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test error: %v", err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
releasePath := "/dists/bookworm/Release"
|
||||
packagesPath := "/dists/bookworm/main/binary-amd64/Packages.gz"
|
||||
|
||||
resp := doRequest(releasePath)
|
||||
if resp.StatusCode != fiber.StatusOK {
|
||||
t.Fatalf("expected 200 for release, got %d", resp.StatusCode)
|
||||
}
|
||||
if resp.Header.Get("X-Any-Hub-Cache-Hit") != "false" {
|
||||
t.Fatalf("expected cache miss for first release fetch")
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
resp2 := doRequest(releasePath)
|
||||
if resp2.StatusCode != fiber.StatusOK {
|
||||
t.Fatalf("expected 200 for cached release, got %d", resp2.StatusCode)
|
||||
}
|
||||
if resp2.Header.Get("X-Any-Hub-Cache-Hit") != "true" {
|
||||
t.Fatalf("expected cache hit for release")
|
||||
}
|
||||
resp2.Body.Close()
|
||||
|
||||
pkgResp := doRequest(packagesPath)
|
||||
if pkgResp.StatusCode != fiber.StatusOK {
|
||||
t.Fatalf("expected 200 for packages, got %d", pkgResp.StatusCode)
|
||||
}
|
||||
if pkgResp.Header.Get("X-Any-Hub-Cache-Hit") != "false" {
|
||||
t.Fatalf("expected cache miss for packages")
|
||||
}
|
||||
pkgResp.Body.Close()
|
||||
|
||||
pkgResp2 := doRequest(packagesPath)
|
||||
if pkgResp2.StatusCode != fiber.StatusOK {
|
||||
t.Fatalf("expected 200 for cached packages, got %d", pkgResp2.StatusCode)
|
||||
}
|
||||
if pkgResp2.Header.Get("X-Any-Hub-Cache-Hit") != "true" {
|
||||
t.Fatalf("expected cache hit for packages")
|
||||
}
|
||||
pkgResp2.Body.Close()
|
||||
|
||||
if stub.ReleaseGets() != 1 {
|
||||
t.Fatalf("expected single release GET, got %d", stub.ReleaseGets())
|
||||
}
|
||||
if stub.ReleaseHeads() != 1 {
|
||||
t.Fatalf("expected single release HEAD revalidate, got %d", stub.ReleaseHeads())
|
||||
}
|
||||
if stub.PackagesGets() != 1 {
|
||||
t.Fatalf("expected single packages GET, got %d", stub.PackagesGets())
|
||||
}
|
||||
if stub.PackagesHeads() != 1 {
|
||||
t.Fatalf("expected single packages HEAD revalidate, got %d", stub.PackagesHeads())
|
||||
}
|
||||
}
|
||||
|
||||
type aptStub struct {
|
||||
server *http.Server
|
||||
listener net.Listener
|
||||
URL string
|
||||
mu sync.Mutex
|
||||
releaseBody string
|
||||
packagesBody string
|
||||
releaseETag string
|
||||
packagesETag string
|
||||
releaseGets int
|
||||
releaseHeads int
|
||||
packagesGets int
|
||||
packagesHeads int
|
||||
releasePath string
|
||||
packagesPath string
|
||||
}
|
||||
|
||||
func newAptStub(t *testing.T) *aptStub {
|
||||
t.Helper()
|
||||
stub := &aptStub{
|
||||
releaseBody: "Release-body",
|
||||
packagesBody: "Packages-body",
|
||||
releaseETag: "r1",
|
||||
packagesETag: "p1",
|
||||
releasePath: "/dists/bookworm/Release",
|
||||
packagesPath: "/dists/bookworm/main/binary-amd64/Packages.gz",
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc(stub.releasePath, stub.handleRelease)
|
||||
mux.HandleFunc(stub.packagesPath, stub.handlePackages)
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("unable to start apt stub: %v", err)
|
||||
}
|
||||
|
||||
srv := &http.Server{Handler: mux}
|
||||
stub.server = srv
|
||||
stub.listener = listener
|
||||
stub.URL = "http://" + listener.Addr().String()
|
||||
|
||||
go func() {
|
||||
_ = srv.Serve(listener)
|
||||
}()
|
||||
return stub
|
||||
}
|
||||
|
||||
func (s *aptStub) handleRelease(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if r.Method == http.MethodHead {
|
||||
s.releaseHeads++
|
||||
if matchETag(r, s.releaseETag) {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
writeHeaders(w, s.releaseETag)
|
||||
return
|
||||
}
|
||||
s.releaseGets++
|
||||
writeHeaders(w, s.releaseETag)
|
||||
_, _ = w.Write([]byte(s.releaseBody))
|
||||
}
|
||||
|
||||
func (s *aptStub) handlePackages(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if r.Method == http.MethodHead {
|
||||
s.packagesHeads++
|
||||
if matchETag(r, s.packagesETag) {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
writeHeaders(w, s.packagesETag)
|
||||
w.Header().Set("Content-Type", "application/gzip")
|
||||
return
|
||||
}
|
||||
s.packagesGets++
|
||||
writeHeaders(w, s.packagesETag)
|
||||
w.Header().Set("Content-Type", "application/gzip")
|
||||
_, _ = w.Write([]byte(s.packagesBody))
|
||||
}
|
||||
|
||||
func matchETag(r *http.Request, etag string) bool {
|
||||
for _, candidate := range r.Header.Values("If-None-Match") {
|
||||
c := strings.Trim(candidate, "\"")
|
||||
if c == etag || candidate == etag {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func writeHeaders(w http.ResponseWriter, etag string) {
|
||||
w.Header().Set("ETag", "\""+etag+"\"")
|
||||
w.Header().Set("Last-Modified", time.Now().UTC().Format(http.TimeFormat))
|
||||
}
|
||||
|
||||
func (s *aptStub) ReleaseGets() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.releaseGets
|
||||
}
|
||||
|
||||
func (s *aptStub) ReleaseHeads() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.releaseHeads
|
||||
}
|
||||
|
||||
func (s *aptStub) PackagesGets() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.packagesGets
|
||||
}
|
||||
|
||||
func (s *aptStub) PackagesHeads() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.packagesHeads
|
||||
}
|
||||
|
||||
func (s *aptStub) Close() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
if s.server != nil {
|
||||
_ = s.server.Shutdown(ctx)
|
||||
}
|
||||
if s.listener != nil {
|
||||
_ = s.listener.Close()
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,14 @@ package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -42,6 +44,7 @@ func TestCacheFlowWithConditionalRequest(t *testing.T) {
|
||||
Name: "docker",
|
||||
Domain: "docker.hub.local",
|
||||
Type: "docker",
|
||||
Module: "docker",
|
||||
Upstream: upstream.URL,
|
||||
},
|
||||
},
|
||||
@@ -143,6 +146,7 @@ func TestDockerManifestHeadDoesNotOverwriteCache(t *testing.T) {
|
||||
Name: "docker",
|
||||
Domain: "docker.hub.local",
|
||||
Type: "docker",
|
||||
Module: "docker",
|
||||
Upstream: upstream.URL,
|
||||
},
|
||||
},
|
||||
@@ -294,6 +298,7 @@ type cacheFlowStub struct {
|
||||
lastRequest *http.Request
|
||||
body []byte
|
||||
etag string
|
||||
etagVer int
|
||||
lastMod string
|
||||
}
|
||||
|
||||
@@ -302,6 +307,7 @@ func newCacheFlowStub(t *testing.T, paths ...string) *cacheFlowStub {
|
||||
stub := &cacheFlowStub{
|
||||
body: []byte("upstream payload"),
|
||||
etag: `"etag-v1"`,
|
||||
etagVer: 1,
|
||||
lastMod: time.Now().UTC().Format(http.TimeFormat),
|
||||
}
|
||||
|
||||
@@ -344,6 +350,8 @@ func (s *cacheFlowStub) Close() {
|
||||
|
||||
func (s *cacheFlowStub) handle(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.Lock()
|
||||
etag := s.etag
|
||||
lastMod := s.lastMod
|
||||
if r.Method == http.MethodHead {
|
||||
s.headHits++
|
||||
} else {
|
||||
@@ -354,15 +362,21 @@ func (s *cacheFlowStub) handle(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if r.Method == http.MethodHead {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Header().Set("Etag", s.etag)
|
||||
w.Header().Set("Last-Modified", s.lastMod)
|
||||
w.Header().Set("Etag", etag)
|
||||
w.Header().Set("Last-Modified", lastMod)
|
||||
for _, candidate := range r.Header.Values("If-None-Match") {
|
||||
if strings.Trim(candidate, `"`) == strings.Trim(etag, `"`) {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Header().Set("Etag", s.etag)
|
||||
w.Header().Set("Last-Modified", s.lastMod)
|
||||
w.Header().Set("Etag", etag)
|
||||
w.Header().Set("Last-Modified", lastMod)
|
||||
_, _ = w.Write(s.body)
|
||||
}
|
||||
|
||||
@@ -370,5 +384,7 @@ func (s *cacheFlowStub) UpdateBody(body []byte) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.body = body
|
||||
s.lastMod = time.Now().UTC().Format(http.TimeFormat)
|
||||
s.etagVer++
|
||||
s.etag = fmt.Sprintf(`"etag-v%d"`, s.etagVer)
|
||||
s.lastMod = time.Now().UTC().Add(2 * time.Second).Format(http.TimeFormat)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"github.com/any-hub/any-hub/internal/config"
|
||||
"github.com/any-hub/any-hub/internal/hubmodule"
|
||||
"github.com/any-hub/any-hub/internal/hubmodule/legacy"
|
||||
"github.com/any-hub/any-hub/internal/proxy/hooks"
|
||||
"github.com/any-hub/any-hub/internal/server"
|
||||
"github.com/any-hub/any-hub/internal/server/routes"
|
||||
@@ -41,6 +42,8 @@ func TestModuleDiagnosticsEndpoints(t *testing.T) {
|
||||
Domain: "legacy.local",
|
||||
Type: "docker",
|
||||
Upstream: "https://registry-1.docker.io",
|
||||
Module: hubmodule.DefaultModuleKey(),
|
||||
Rollout: string(legacy.RolloutLegacyOnly),
|
||||
},
|
||||
{
|
||||
Name: "modern-hub",
|
||||
|
||||
@@ -239,7 +239,7 @@ func (s *pypiStub) handleWheel(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *pypiStub) UpdateSimple(body []byte) {
|
||||
s.mu.Lock()
|
||||
s.simpleBody = append([]byte(nil), body...)
|
||||
s.lastSimpleMod = time.Now().UTC().Format(http.TimeFormat)
|
||||
s.lastSimpleMod = time.Now().UTC().Add(2 * time.Second).Format(http.TimeFormat)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user