Merge pull request #9 from entirehq/nodo/follow-info-refs-redirects · Entire
Merge pull request #9 from entirehq/nodo/follow-info-refs-redirects
66ab8c4→main·
nodo·2mo ago·11 files·+403 added/-13 removed
smarthttp: optionally follow info/refs redirects into Endpoint
Changes
11
cmd/git-sync
Mmain.go+147/-2
Mmain_test.go+41
internal
gitproto
Msmarthttp.go+17
Msmarthttp_test.go+137
syncer
Msyncer.go+9/-1
Msyncer_test.go+23
pkg/gitsync
Mclient.go+4/-1
internalbridge
Mconfig.go+8/-6
Mtypes.go+8
unstable
Mclient.go+4/-1
Mclient_test.go+5/-2
58 unmodified lines
fs.StringVar(&req.Source.URL, "source-url", "", "source repository URL")
fs.StringVar(&req.Target.URL, "target-url", "", "target repository URL")
fs.BoolVar(&req.Source.FollowInfoRefsRedirect, "source-follow-info-refs-redirect", envBool("GITSYNC_SOURCE_FOLLOW_INFO_REFS_REDIRECT"), "send follow-up source RPCs to the final /info/refs redirect host")
fs.BoolVar(&req.Target.FollowInfoRefsRedirect, "target-follow-info-refs-redirect", envBool("GITSYNC_TARGET_FOLLOW_INFO_REFS_REDIRECT"), "send follow-up target RPCs to the final /info/refs redirect host")
fs.StringVar(&sourceAuth.Token, "source-token", envOr("GITSYNC_SOURCE_TOKEN", ""), "source token/password")
fs.StringVar(&targetAuth.Token, "target-token", envOr("GITSYNC_TARGET_TOKEN", ""), "target token/password")
98 unmodified lines
fs.StringVar(&req.Source.URL, "source-url", "", "source repository URL") fs.StringVar(&req.Target.URL, "target-url", "", "target repository URL") fs.BoolVar(&req.Source.FollowInfoRefsRedirect, "source-follow-info-refs-redirect", envBool("GITSYNC_SOURCE_FOLLOW_INFO_REFS_REDIRECT"), "send follow-up source RPCs to the final /info/refs redirect host") fs.BoolVar(&targetFollowInfoRefsRedirect, "target-follow-info-refs-redirect", envBool("GITSYNC_TARGET_FOLLOW_INFO_REFS_REDIRECT"), "send follow-up target RPCs to the final /info/refs redirect host") fs.StringVar(&sourceAuth.Token, "source-token", envOr("GITSYNC_SOURCE_TOKEN", ""), "source token/password") fs.StringVar(&targetAuth.Token, "target-token", envOr("GITSYNC_TARGET_TOKEN", ""), "target token/password")
68 unmodified lines
var jsonOutput bool
var sourceAuth gitsync.EndpointAuth
var targetAuth gitsync.EndpointAuth
var targetFollowInfoRefsRedirect bool
req := unstable.ProbeRequest{}
fs.StringVar(&req.Source.URL, "source-url", "", "source repository URL")
targetURL := fs.String("target-url", "", "optional target repository URL")
fs.BoolVar(&req.Source.FollowInfoRefsRedirect, "source-follow-info-refs-redirect", envBool("GITSYNC_SOURCE_FOLLOW_INFO_REFS_REDIRECT"), "send follow-up source RPCs to the final /info/refs redirect host")
fs.BoolVar(&targetFollowInfoRefsRedirect, "target-follow-info-refs-redirect", envBool("GITSYNC_TARGET_FOLLOW_INFO_REFS_REDIRECT"), "send follow-up target RPCs to the final /info/refs redirect host")
fs.StringVar(&sourceAuth.Token, "source-token", envOr("GITSYNC_SOURCE_TOKEN", ""), "source token/password")
fs.StringVar(&targetAuth.Token, "target-token", envOr("GITSYNC_TARGET_TOKEN", ""), "target token/password")
...
...
...
...
// TestRequestInfoRefs_FollowInfoRefsRedirect verifies that when the flag is // set, a 307 on /info/refs rewrites Conn.Endpoint.Host so subsequent PostRPC // calls target the redirected node. Matches vanilla git's smart-HTTP // behaviour and lets clients use a cluster entry domain for info/refs while // packs land on the hosting replica. func TestRequestInfoRefs_FollowInfoRefsRedirect(t *testing.T) { sourceRepo, sourceFS := newSourceRepo(t) makeCommits(t, sourceRepo, sourceFS, 1)
sourceServer := newSmartHTTPRepoServer(t, sourceRepo) defer sourceServer.Close()
entry := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodGet && r.URL.Path == sourceServer.repoPath+"/info/refs": http.Redirect(w, r, sourceServer.server.URL+r.URL.Path+"?"+r.URL.RawQuery, http.StatusTemporaryRedirect) case r.Method == http.MethodPost: http.Error(w, "entry domain rejects packs", http.StatusMethodNotAllowed) default: http.NotFound(w, r) } }))
defer entry.Close()
output, err := captureStdout(func() error { return run(context.Background(), []string{ "fetch", "--source-follow-info-refs-redirect", "--branch", testBranch, "--json", entry.URL + sourceServer.repoPath, }) })
if err != nil { t.Fatalf("run fetch: %v", err) }
var result map[string]any if err := json.Unmarshal([]byte(output), &result); err != nil { t.Fatalf("decode fetch json: %v\noutput=%s", err, output) } if got, ok := result["fetchedObjects"].(float64); !ok || got == 0 { t.Fatalf("expected fetched objects from redirected source, got %#v", result["fetchedObjects"]) } }
// TestRequestInfoRefs_FollowInfoRefsRedirect_SubsequentPOSTHitsRedirectedHost
// is the reviewer-requested integration test: it runs the full sequence
// (GET /info/refs → 307 → 200 on hosting node → POST /git-upload-pack) and
// asserts the POST lands on the hosting node, not the entry domain. This is
// the property that makes the flag useful — the whole point is that packs
// follow info/refs.
func TestRequestInfoRefs_FollowInfoRefsRedirect_SubsequentPOSTHitsRedirectedHost(t *testing.T) {
var nodeGotPOST bool
node := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/info/refs"):
w.Header().Set("Content-Type", "application/x-git-upload-pack-advertisement")
if _, err := w.Write([]byte("001e# service=git-upload-pack\n0000")); err != nil {
t.Errorf("node info/refs write: %v", err)
}
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/git-upload-pack"):
nodeGotPOST = true
w.Header().Set("Content-Type", "application/x-git-upload-pack-result")
w.WriteHeader(http.StatusOK)
default:
t.Errorf("node: unexpected %s %s", r.Method, r.URL.Path)
}
}))
defer node.Close()
var entryGotPOST bool
entry := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
entryGotPOST = true
http.Error(w, "LB rejects packs", http.StatusMethodNotAllowed)
return
}
http.Redirect(w, r, node.URL+r.URL.Path+"?"+r.URL.RawQuery, http.StatusTemporaryRedirect)
}))
defer entry.Close()
ep, err := transport.NewEndpoint(entry.URL + "/repo.git")
if err != nil {
t.Fatalf("parse endpoint: %v", err)
}
conn := NewConn(ep, "test", nil, http.DefaultTransport)
conn.FollowInfoRefsRedirect = true
if _, err := RequestInfoRefs(t.Context(), conn, transport.UploadPackService, ""); err != nil {
t.Fatalf("RequestInfoRefs: %v", err)
}
// Now do the follow-up upload-pack POST. Without the flag this hits the
// entry domain (rejected with 405); with the flag it hits the node.
body, err := PostRPC(t.Context(), conn, transport.UploadPackService, []byte("0000"), false, "upload-pack integration-test")
if err != nil {
t.Fatalf("PostRPC: %v", err)
}
if len(body) != 0 {
// body shape is not what we're asserting; just demand it didn't fail
_ = body
}
if entryGotPOST {
t.Error("POST hit the entry domain instead of the redirected node")
}
if !nodeGotPOST {
t.Error("POST did not hit the redirected node")
}
}
// TestRequestInfoRefs_DoesNotFollowByDefault confirms the default behaviour
// is unchanged: Endpoint is stable even if the server 307s.
func TestRequestInfoRefs_DoesNotFollowByDefault(t *testing.T) {
node := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/x-git-upload-pack-advertisement")
if _, err := w.Write([]byte("001e# service=git-upload-pack\n0000")); err != nil {
t.Errorf("node write: %v", err)
}
}))
defer node.Close()
entry := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, node.URL+r.URL.Path+"?"+r.URL.RawQuery, http.StatusTemporaryRedirect)
}))
defer entry.Close()
ep, err := transport.NewEndpoint(entry.URL + "/repo.git")
if err != nil {
t.Fatalf("parse endpoint: %v", err)
}
entryHost := ep.Host
conn := NewConn(ep, "test", nil, http.DefaultTransport)
// FollowInfoRefsRedirect intentionally not set.
if _, err := RequestInfoRefs(t.Context(), conn, transport.UploadPackService, ""); err != nil {
t.Fatalf("RequestInfoRefs: %v", err)
}
if conn.Endpoint.Host != entryHost {
t.Errorf("Endpoint.Host = %q, want %q (endpoint should be unchanged by default)", conn.Endpoint.Host, entryHost)
}
}
// Minternal/gitsync/client.go+4/-1
27 unmodified lines
}
// TestNewConn_PropagatesFollowInfoRefsRedirect proves the plumbing from // Endpoint → gitproto.Conn is in place. Without this the flag on // Endpoint is dead config. func TestNewConn_PropagatesFollowInfoRefsRedirect(t *testing.T) { stats := newStats(false)
off, err := newConn(Endpoint{URL: "https://node.example/repo.git"}, "target", stats, nil) if err != nil { t.Fatalf("new conn (off): %v", err) } if off.FollowInfoRefsRedirect { t.Error("FollowInfoRefsRedirect should default to false") }
on, err := newConn(Endpoint{URL: "https://node.example/repo.git", FollowInfoRefsRedirect: true}, "target", stats, nil) if err != nil { t.Fatalf("new conn (on): %v", err) } if !on.FollowInfoRefsRedirect { t.Error("FollowInfoRefsRedirect was not propagated from Endpoint to Conn") } }
// Minternal/syncer/syncer_test.go+23
192 unmodified lines
}
// Mpkg/gitsync/types.go+8
292 unmodified lines
func syncerEndpoint(endpoint gitsync.Endpoint, auth gitsync.EndpointAuth) syncer.Endpoint { return internalbridge.ToSyncerEndpoint( internalbridge.Endpoint{URL: endpoint.URL}, internalbridge.Endpoint{ URL: endpoint.URL, FollowInfoRefsRedirect: endpoint.FollowInfoRefsRedirect, }, internalbridge.EndpointAuth{ Username: auth.Username, Token: auth.Token, BearerToken: auth.BearerToken, }, ) }