Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
4 changes: 4 additions & 0 deletions cmd/forgemark/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,10 @@ func parseFlags() (*cliConfig, error) {
flag.StringVar(&cfg.out, "out", "", "write JSON results here (default: results/forgemark-<id>.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.
Expand Down
18 changes: 10 additions & 8 deletions internal/bench/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,13 @@ type agent struct {
ref string // destination ref, e.g. refs/forgemark/<run>-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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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),
Expand Down
158 changes: 158 additions & 0 deletions internal/bench/agent_test.go
Original file line number Diff line number Diff line change
@@ -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("<html>not git</html>")); 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()
}
2 changes: 1 addition & 1 deletion internal/bench/cleanup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
7 changes: 7 additions & 0 deletions internal/bench/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions internal/bench/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
55 changes: 55 additions & 0 deletions internal/bench/endpoint.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package bench

import (
"bytes"
"context"
"errors"
"fmt"
Expand All @@ -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"
)
Expand Down Expand Up @@ -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.
Expand Down
8 changes: 7 additions & 1 deletion internal/bench/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions internal/bench/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down
Loading