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

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, }, ) }