diff --git a/README.md b/README.md index cd05ca9..921e3b5 100644 --- a/README.md +++ b/README.md @@ -282,6 +282,7 @@ modest, or point `-remote` at a self-managed GitLab you control. | `-strategy` | `branch` | `branch` \| `repo` \| `clone` \| `session` | | `-ref-namespace` | `refs/forgemark/` | where pushed refs land; the default stays off `refs/heads/` so the forge's post-receive pipeline (search ingestion, CI, webhooks) never sees bench pushes. `refs/heads/` pushes real branches | | `-branch-prefix` | — | prepended verbatim inside the namespace, before the run ID (e.g. `bench/` → `refs/forgemark/bench/fm...`); groups refs for easy cleanup | +| `-push-options` | — | push option sent with every push, like `git push -o` (e.g. `ci.skip`); repeat the flag for several. The run refuses to start unless every repo advertises the `push-options` capability, since go-git would otherwise drop them silently | | `-concurrency` | `1,8,32,128` | swept sequentially, one row each | | `-duration` / `-warmup` | `60s` / `10s` | measured window / discarded ramp | | `-files-min`/`-files-max`/`-file-size` | `1`/`10`/`2048` | commit shape; ignored by `clone` | diff --git a/cmd/forgemark/main.go b/cmd/forgemark/main.go index 110ed28..af9adb6 100644 --- a/cmd/forgemark/main.go +++ b/cmd/forgemark/main.go @@ -198,6 +198,10 @@ func parseFlags() (*cliConfig, error) { flag.StringVar(&cfg.out, "out", "", "write JSON results here (default: results/forgemark-.json)") flag.IntVar(&cfg.workload.SessionCommits, "session-commits", 5, "session strategy: commit+push checkpoints per cloned session") flag.IntVar(&cfg.workload.CloneDepth, "clone-depth", 1, "clone/session strategy: shallow clone depth (1=tip; 0=full history)") + flag.Func("push-options", "push option sent with every push, like git push -o (repeatable; one option per flag, so values may contain commas). The run refuses to start unless the server advertises push-options", func(v string) error { + cfg.workload.PushOptions = append(cfg.workload.PushOptions, v) + return nil + }) flag.StringVar(&cfg.workload.BaseRef, "base-ref", "", "clone/session strategy: branch to clone — bare name (main) or full ref (refs/heads/main); default: remote default branch") // entiredb: presence of -token-url/-jurisdiction selects the entiredb path. diff --git a/internal/bench/agent.go b/internal/bench/agent.go index fdefe08..18a15b1 100644 --- a/internal/bench/agent.go +++ b/internal/bench/agent.go @@ -38,12 +38,13 @@ type agent struct { ref string // destination ref, e.g. refs/forgemark/-c8-a7 (see DestRef) objFmt formatcfg.ObjectFormat - cfg *CommitConfig - creds credentialProvider - httpc *http.Client - clone *cloneConfig // non-nil → "clone" strategy (clone-only loop) - sess *sessionConfig // non-nil → "session" strategy (clone+push loop) - sink Sink // optional live observer; nil on the CLI path + cfg *CommitConfig + creds credentialProvider + httpc *http.Client + pushOpts []string // push options sent with every push (git push -o) + clone *cloneConfig // non-nil → "clone" strategy (clone-only loop) + sess *sessionConfig // non-nil → "session" strategy (clone+push loop) + sink Sink // optional live observer; nil on the CLI path repo *git.Repository samples []Sample @@ -79,10 +80,10 @@ const resetEvery = 128 // newAgent builds an agent. In branch/repo mode it initialises its in-memory // repo up front (object format matched to the remote). In clone/session mode // the repo is created per clone by the clone loop, so init is skipped. -func newAgent(id int, repoPath, node, ref string, objFmt formatcfg.ObjectFormat, cfg *CommitConfig, creds credentialProvider, httpc *http.Client, clone *cloneConfig, sess *sessionConfig, sink Sink) (*agent, error) { +func newAgent(id int, repoPath, node, ref string, objFmt formatcfg.ObjectFormat, cfg *CommitConfig, creds credentialProvider, httpc *http.Client, pushOpts []string, clone *cloneConfig, sess *sessionConfig, sink Sink) (*agent, error) { a := &agent{ id: id, repoPath: repoPath, node: node, ref: ref, objFmt: objFmt, - cfg: cfg, creds: creds, httpc: httpc, clone: clone, sess: sess, sink: sink, + cfg: cfg, creds: creds, httpc: httpc, pushOpts: pushOpts, clone: clone, sess: sess, sink: sink, } if clone == nil && sess == nil { if err := a.initRepo(); err != nil { @@ -206,6 +207,7 @@ func (a *agent) push(ctx context.Context) error { RemoteName: "origin", RefSpecs: []config.RefSpec{config.RefSpec("+refs/heads/master:" + a.ref)}, Force: true, // each agent owns its ref; force keeps numbers clean of spurious non-ff + Options: a.pushOpts, ClientOptions: []gitclient.Option{ gitclient.WithHTTPClient(a.httpc), gitclient.WithHTTPAuth(auth), diff --git a/internal/bench/agent_test.go b/internal/bench/agent_test.go new file mode 100644 index 0000000..83a87c1 --- /dev/null +++ b/internal/bench/agent_test.go @@ -0,0 +1,158 @@ +package bench + +import ( + "bytes" + "fmt" + "io" + "math/rand" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" + + "github.com/go-git/go-git/v6/backend" + formatcfg "github.com/go-git/go-git/v6/plumbing/format/config" + "github.com/go-git/go-git/v6/storage" + "github.com/go-git/go-git/v6/storage/memory" +) + +type storerLoader struct{ st storage.Storer } + +func (l storerLoader) Load(*url.URL) (storage.Storer, error) { return l.st, nil } + +// receivePackBodies serves one in-memory repo over smart HTTP and keeps the +// body of every receive-pack request, where push options travel. +func receivePackBodies(t *testing.T) (*httptest.Server, func() []string) { + t.Helper() + var ( + mu sync.Mutex + bodies []string + ) + h := backend.New(storerLoader{memory.NewStorage()}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/git-receive-pack") { + b, err := io.ReadAll(r.Body) + if err != nil { + t.Error(err) + } + mu.Lock() + bodies = append(bodies, string(b)) + mu.Unlock() + r.Body = io.NopCloser(bytes.NewReader(b)) + } + h.ServeHTTP(w, r) + })) + t.Cleanup(srv.Close) + return srv, func() []string { + mu.Lock() + defer mu.Unlock() + return bodies + } +} + +func TestAgentPushSendsPushOptions(t *testing.T) { + t.Parallel() + + srv, bodies := receivePackBodies(t) + opts := []string{"ci.skip", "merge_request.label=a,b"} + a, err := newAgent(0, "repo.git", srv.URL, "refs/forgemark/fmX-c1-a0", formatcfg.SHA1, + &CommitConfig{FilesMin: 1, FilesMax: 1, FileSize: 16}, + staticCreds{username: "u", password: "p"}, srv.Client(), opts, nil, nil, nil) + if err != nil { + t.Fatal(err) + } + if err := a.commit(rand.New(rand.NewSource(1)), 0); err != nil { + t.Fatal(err) + } + if err := a.push(t.Context()); err != nil { + t.Fatal(err) + } + a.deleteRef(a.ref) + + got := bodies() + if len(got) != 2 { + t.Fatalf("server saw %d receive-pack requests, want a push and a delete", len(got)) + } + for i, body := range got { + for _, o := range opts { + if !strings.Contains(body, o) { + t.Errorf("receive-pack request %d is missing push option %q", i, o) + } + } + } +} + +// receivePackAdvertisement is a smart-HTTP receive-pack info/refs reply for an +// empty repo advertising caps. +func receivePackAdvertisement(caps string) string { + pkt := func(s string) string { return fmt.Sprintf("%04x%s", len(s)+4, s) } + return pkt("# service=git-receive-pack\n") + "0000" + + pkt("0000000000000000000000000000000000000000 capabilities^{}\x00"+caps+"\n") + "0000" +} + +func TestAdvertisesPushOptions(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + caps string + want bool + }{ + {"report-status delete-refs push-options", true}, + {"report-status delete-refs", false}, + {"report-status push-optionsx", false}, + } { + got, err := advertisesPushOptions([]byte(receivePackAdvertisement(tc.caps))) + if err != nil { + t.Fatalf("caps %q: %v", tc.caps, err) + } + if got != tc.want { + t.Errorf("caps %q: advertisesPushOptions = %v, want %v", tc.caps, got, tc.want) + } + } + if _, err := advertisesPushOptions([]byte("not git")); err == nil { + t.Error("a non-git reply must be an error, not a missing capability") + } +} + +// go-git drops push options the server doesn't advertise and the push still +// succeeds, so the run must refuse to start rather than measure pushes that +// differ from the ones requested. +func TestNewRunnerRequiresPushOptionsCapability(t *testing.T) { + t.Parallel() + + noCaps := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/x-git-receive-pack-advertisement") + _, _ = io.WriteString(w, receivePackAdvertisement("report-status delete-refs")) + })) + defer noCaps.Close() + withCaps, _ := receivePackBodies(t) + + w := Workload{ + Strategy: "branch", Concurrency: []int{1}, Duration: time.Second, + Commit: CommitConfig{FilesMin: 1, FilesMax: 1, FileSize: 1}, + } + target := func(remote string) Target { + return Target{Remote: remote, Repos: []string{"repo.git"}, Secret: "s"} + } + + if r, err := NewRunner(t.Context(), target(noCaps.URL), w, nil); err != nil { + t.Fatalf("without push options the capability must not be probed: %v", err) + } else { + r.Close() + } + + w.PushOptions = []string{"ci.skip"} + _, err := NewRunner(t.Context(), target(noCaps.URL), w, nil) + if err == nil || !strings.Contains(err.Error(), "push-options") { + t.Fatalf("NewRunner against a server without push-options = %v, want a push-options error", err) + } + + r, err := NewRunner(t.Context(), target(withCaps.URL), w, nil) + if err != nil { + t.Fatalf("NewRunner against a server advertising push-options: %v", err) + } + r.Close() +} diff --git a/internal/bench/cleanup.go b/internal/bench/cleanup.go index 883e7da..24ea95d 100644 --- a/internal/bench/cleanup.go +++ b/internal/bench/cleanup.go @@ -193,7 +193,7 @@ func (r *Runner) cleanRepoRefs(ctx context.Context, repoPath string, re *regexp. // objects, so a bare remote over an empty storer suffices. NoErrAlreadyUpToDate // means nothing matching the spec(s) remained — already gone counts as done. push := func(specs []config.RefSpec) error { - err := remote.PushContext(ctx, &git.PushOptions{RemoteName: "origin", RefSpecs: specs, ClientOptions: opts}) + err := remote.PushContext(ctx, &git.PushOptions{RemoteName: "origin", RefSpecs: specs, Options: r.w.PushOptions, ClientOptions: opts}) if errors.Is(err, git.NoErrAlreadyUpToDate) { return nil } diff --git a/internal/bench/config.go b/internal/bench/config.go index b2e59fb..ff09eec 100644 --- a/internal/bench/config.go +++ b/internal/bench/config.go @@ -77,6 +77,7 @@ type Workload struct { Duration time.Duration Warmup time.Duration Commit CommitConfig + PushOptions []string // sent with every push, like git push -o // session/clone strategies SessionCommits int @@ -137,6 +138,12 @@ func (w Workload) Validate() error { return fmt.Errorf("-files-max × -file-size must be <= %dMiB per commit", maxCommitBytes>>20) } } + // Push options are newline-delimited on the wire, as git itself enforces. + for _, o := range w.PushOptions { + if strings.ContainsAny(o, "\n\x00") { + return fmt.Errorf("invalid -push-options %q: must not contain newline or NUL", o) + } + } // Validate the assembled ref, not the parts alone: validity is context-dependent // (a trailing "/" or bare word is fine mid-ref, invalid standalone). c/a are arbitrary — // only the namespace and prefix can invalidate it — so these parse-time checks cover diff --git a/internal/bench/config_test.go b/internal/bench/config_test.go index 82990cb..9024736 100644 --- a/internal/bench/config_test.go +++ b/internal/bench/config_test.go @@ -159,3 +159,24 @@ func TestNamespace(t *testing.T) { t.Errorf("Validate(bad prefix) = %v, want -branch-prefix error", err) } } + +func TestValidatePushOptions(t *testing.T) { + t.Parallel() + + w := Workload{ + Strategy: "branch", Concurrency: []int{1}, Duration: time.Second, + Commit: CommitConfig{FilesMin: 1, FilesMax: 1, FileSize: 1}, + } + for _, opts := range [][]string{nil, {"ci.skip"}, {"a=b,c", "x y"}} { + w.PushOptions = opts + if err := w.Validate(); err != nil { + t.Errorf("Validate(push options %q) = %v, want nil", opts, err) + } + } + for _, opts := range [][]string{{"a\nb"}, {"ok", "nul\x00"}} { + w.PushOptions = opts + if err := w.Validate(); err == nil { + t.Errorf("Validate(push options %q) = nil, want error", opts) + } + } +} diff --git a/internal/bench/endpoint.go b/internal/bench/endpoint.go index beffb00..e5a885d 100644 --- a/internal/bench/endpoint.go +++ b/internal/bench/endpoint.go @@ -1,6 +1,7 @@ package bench import ( + "bytes" "context" "errors" "fmt" @@ -11,6 +12,9 @@ import ( "strings" formatcfg "github.com/go-git/go-git/v6/plumbing/format/config" + "github.com/go-git/go-git/v6/plumbing/format/pktline" + "github.com/go-git/go-git/v6/plumbing/protocol/capability" + "github.com/go-git/go-git/v6/plumbing/protocol/packp" githttp "github.com/go-git/go-git/v6/plumbing/transport/http" "golang.org/x/net/publicsuffix" ) @@ -204,6 +208,57 @@ func probeInfoRefs(ctx context.Context, c *http.Client, node, repo string, auth redactSecrets(strings.TrimSpace(string(body)), authForms(auth.Username, auth.Password)...), hint) } +// requirePushOptions fails unless every node advertises the push-options +// capability for every repo. go-git drops push options a server doesn't +// advertise and the push still succeeds, so the run would otherwise measure +// pushes that differ from the ones requested (a dropped ci.skip runs CI on +// every push). The capability is per repo in git (receive.advertisePushOptions), +// hence every pair is probed. +func requirePushOptions(ctx context.Context, c *http.Client, nodes, repos []string, creds credentialProvider) error { + for _, repo := range repos { + auth, err := creds.basicAuth(ctx, repo) + if err != nil { + return err + } + for _, node := range nodes { + hdr, body, err := probeInfoRefs(ctx, c, node, repo, auth) + if err != nil { + return fmt.Errorf("push-options probe: %w", err) + } + if hdr.Get("Location") != "" { + return fmt.Errorf("push-options probe: %s redirected; refusing to follow", verbatimURLFor(node, repo)) + } + ok, err := advertisesPushOptions(body) + if err != nil { + return fmt.Errorf("push-options probe: %s: %w", verbatimURLFor(node, repo), err) + } + if !ok { + return fmt.Errorf("%s does not advertise the push-options capability, so -push-options would be silently dropped", verbatimURLFor(node, repo)) + } + } + } + return nil +} + +// advertisesPushOptions reports whether a smart-HTTP receive-pack info/refs +// reply advertises the push-options capability. +func advertisesPushOptions(body []byte) (bool, error) { + r := bytes.NewReader(body) + // Smart HTTP prefixes the advertisement with a service line and a flush. + if _, p, err := pktline.ReadLine(r); err == nil && bytes.HasPrefix(p, []byte("# service=")) { + if l, _, err := pktline.ReadLine(r); err != nil || l != pktline.Flush { + return false, errors.New("malformed service header in receive-pack advertisement") + } + } else { + r.Reset(body) + } + var adv packp.AdvRefs + if err := adv.Decode(r); err != nil { + return false, fmt.Errorf("decode receive-pack advertisement: %w", err) + } + return adv.Capabilities.Supports(capability.PushOptions), nil +} + // redirectNode maps a discovery Location onto the pinned node it names. // Userinfo (Entire embeds the caller's token) and path are dropped: the // probe rebuilds the URL and presents its own credential. diff --git a/internal/bench/runner.go b/internal/bench/runner.go index 4716553..2551cdd 100644 --- a/internal/bench/runner.go +++ b/internal/bench/runner.go @@ -70,6 +70,12 @@ func NewRunner(ctx context.Context, t Target, w Workload, sink Sink) (*Runner, e return nil, err } r := &Runner{target: t, w: w, creds: creds, ep: ep, httpc: httpc, sink: sink, levelsLeft: len(w.Concurrency)} + if len(w.PushOptions) > 0 { + if err := requirePushOptions(ctx, httpc, ep.nodes, t.Repos, creds); err != nil { + r.Close() + return nil, err + } + } if t.TokenURL != "" || t.Jurisdiction != "" { // Only Entire's account token bounds the run. After discovery, so // its time counts against the token. @@ -187,7 +193,7 @@ func (r *Runner) RunLevel(ctx context.Context, c int, barrier *StartBarrier) (Le } node := r.ep.nodes[i%len(r.ep.nodes)] ref := DestRef(r.w.Namespace(), r.w.BranchPrefix, r.w.RunID, c, i) - a, err := newAgent(i, repoPath, node, ref, r.ep.objFmt, &r.w.Commit, r.creds, r.httpc, clone, sess, r.sink) + a, err := newAgent(i, repoPath, node, ref, r.ep.objFmt, &r.w.Commit, r.creds, r.httpc, r.w.PushOptions, clone, sess, r.sink) if err != nil { if barrier != nil { barrier.Arrive() // don't leave the coordinator's Await hanging diff --git a/internal/bench/session.go b/internal/bench/session.go index 2a33ff3..56cb5fc 100644 --- a/internal/bench/session.go +++ b/internal/bench/session.go @@ -193,6 +193,7 @@ func (a *agent) pushRef(ctx context.Context, src plumbing.ReferenceName, dst str RemoteName: "origin", RefSpecs: []config.RefSpec{config.RefSpec("+" + src.String() + ":" + dst)}, Force: true, + Options: a.pushOpts, ClientOptions: []gitclient.Option{ gitclient.WithHTTPClient(a.httpc), gitclient.WithHTTPAuth(auth), @@ -224,6 +225,7 @@ func (a *agent) deleteRef(dst string) { if err := a.repo.PushContext(ctx, &git.PushOptions{ RemoteName: "origin", RefSpecs: []config.RefSpec{config.RefSpec(":" + dst)}, + Options: a.pushOpts, ClientOptions: []gitclient.Option{ gitclient.WithHTTPClient(a.httpc), gitclient.WithHTTPAuth(auth),