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
944 changes: 934 additions & 10 deletions Cargo.lock

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ flate2 = "1"
# foldhash / equivalent deps); the std `HashMap` backend is plenty for this.
lru = { version = "0.18", default-features = false }
prometheus = { version = "0.14", default-features = false }
# LFS objects use an HTTPS API; reqwest fetches them over rustls+ring (no native-tls,
# so the binary stays statically linked). serde_json rewrites the batch JSON; sha2
# verifies a fetched object against its oid before caching.
reqwest = { version = "0.12", default-features = false, features = [
"rustls-tls-native-roots",
] }
serde_json = "1"
sha2 = "0.10"
subtle = "2"
tokio = { version = "1", features = [
"macros",
Expand Down
18 changes: 10 additions & 8 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,18 @@
# dependency of its own. The runtime layer is a bare Alpine that adds only
# `git` + CA certs.
#
# Why not a true "distroless" (gcr.io/distroless/static) image? The proxy
# delegates all wire-protocol work to the system `git` binary, so the runtime
# MUST contain git. distroless/static has no package manager and no git, so it
# cannot host this design as-is. Alpine is the smallest base that still ships a
# git package. A genuinely distroless (git-free) image only becomes possible if
# the git plumbing moves in-process to a Rust library (gitoxide/libgit2) - see
# the "no external git binary" item on the roadmap.
# Why not a true "distroless" (gcr.io/distroless/static) image? The proxy delegates
# the git wire protocol to the system `git` binary, so the runtime MUST contain git.
# distroless/static has no package manager and no git, so it cannot host this design
# as-is. Alpine is the smallest base that still ships a git package. The LFS HTTPS
# transfer, by contrast, is in-process (reqwest + rustls), so it needs no runtime
# tool - only CA certs. A genuinely git-free image only becomes possible if the git
# plumbing also moves in-process - see the "no external git binary" roadmap item.

FROM rust:alpine AS build
RUN apk add --no-cache musl-dev
# musl-dev for the static libc; build-base gives the C toolchain `ring` (rustls'
# crypto provider) compiles its assembly with.
RUN apk add --no-cache build-base
WORKDIR /src
COPY . .
# Default target on rust:alpine is x86_64-unknown-linux-musl (static).
Expand Down
64 changes: 46 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,21 +69,46 @@ anything git-receive-pack -> 403 (read-only)
Concurrent clients for the same repo are serialized so a burst triggers a single
upstream fetch; a short TTL coalesces repeated requests.

### git-LFS

LFS objects use a different HTTP API from the git protocol, so they are cached
separately:

```
POST <repo>/info/lfs/objects/batch
-> forward to upstream, then rewrite each object's download URL back to this
proxy so the object fetch is cached here
GET <repo>/info/lfs/objects/<oid>
-> serve from the on-disk cache, or on a miss fetch it from upstream once
(verify sha256 == oid, store), then serve
anything with operation=upload -> 403 (read-only)
```

Objects are content-addressed and immutable, so a cached object is never stale and
is shared across every repo that references the same oid; the first fetch across the
fleet pays the WAN cost, the rest are served locally. The upstream LFS transfer is
in-process over HTTPS (reqwest + rustls, no OpenSSL), so the binary stays statically
linked. Cached objects share the `--cache-max-mb` budget and LRU eviction with the
git mirrors. No configuration is needed: LFS is served on the same endpoints the git
client already routes through the proxy.

## Benchmark

For a fleet of ephemeral clients cloning the same repo, only the first clone pays
the WAN cost; the rest are served from the local mirror. Cloning a 64 MB repo over
an emulated 20 Mbit/s, 60 ms-RTT link:
For a fleet of ephemeral clients fetching the same content, only the first fetch
pays the WAN cost; the rest are served locally. Over an emulated 20 Mbit/s,
60 ms-RTT link - cloning a 64 MB repo, and fetching a 32 MB git-LFS object:

| Scenario | Clone time | WAN bytes |
| ----------------------------- | ---------: | --------: |
| Direct clone (today) | 28.1 s | 64 MB |
| Via proxy, cold (runner 1) | 28.5 s | 64 MB |
| Via proxy, warm (runner 2..N) | 0.6 s | ~0 MB |
| Scenario | Time | WAN bytes |
| --------------------------------- | ------: | --------: |
| Clone, direct (today) | 28.1 s | 64 MB |
| Clone, via proxy cold (runner 1) | 28.5 s | 64 MB |
| Clone, via proxy warm (2..N) | 0.6 s | ~0 MB |
| LFS object, cold (runner 1) | 12.8 s | 32 MB |
| LFS object, warm (2..N) | 0.2 s | ~0 MB |

The first runner sees no penalty and every subsequent runner clones ~47x faster
The first runner sees no penalty and every subsequent runner is served in-region
while nothing crosses the WAN; the saving scales with fleet size and link cost.
Reproduce or retune (`TOTAL_MB`, `RATE_MBIT`, `RTT_MS`) with
Reproduce or retune (`TOTAL_MB`, `LFS_MB`, `RATE_MBIT`, `RTT_MS`) with
[`bench/run.sh`](./bench/run.sh) - see [`bench/README.md`](./bench/README.md) for
the method and its caveats.

Expand Down Expand Up @@ -151,8 +176,8 @@ Every flag has an environment-variable equivalent.
| `--git-binary` | `GITCACHEPROXY_GIT_BINARY` | `git` | Path to git |

Endpoints: `/healthz`, `/readyz`, `/metrics` (Prometheus - per-repo request and
upstream counters, cache-size gauges, and `*_duration_seconds` fetch/serve
latency histograms).
upstream counters, cache-size gauges, LFS object hit/miss counters, and
`*_duration_seconds` fetch/serve latency histograms).

## Auth model

Expand Down Expand Up @@ -218,11 +243,12 @@ explicit before you expose it:
## Deploy

The `Dockerfile` builds a statically linked (musl) binary and drops it onto a
minimal Alpine base. Because all wire-protocol work is delegated to the system
`git` binary, the runtime image must contain `git` - so it is Alpine-with-git
rather than a fully distroless/`FROM scratch` image. Removing that dependency
(and enabling a git-free image) means moving the git plumbing in-process to a
Rust library - see the roadmap below.
minimal Alpine base. Because the git wire protocol is delegated to the system `git`
binary, the runtime image must contain `git` - so it is Alpine-with-git rather than a
fully distroless/`FROM scratch` image. (The LFS transfer is in-process, so it adds no
runtime tool - only CA certs.) Removing the git dependency and enabling a git-free
image means moving the git plumbing in-process to a Rust library - see the roadmap
below.

On Kubernetes, a Helm chart lives in [`chart/`](./chart) (single-writer
Deployment, `/healthz`+`/readyz` probes, cache PVC, optional Ingress and
Expand All @@ -240,7 +266,9 @@ See the [chart README](./chart/README.md) for the full values reference.
Working and end-to-end tested against both Git wire protocol versions — the
modern **v2** (`git-protocol` header, the default since Git 2.26) and the legacy
**v0/v1** advertisement — covering full clone, incremental delta fetch, and
push rejection.
push rejection. **git-LFS** is cached too: the batch API is proxied and objects are
stored content-addressed on disk, covered by the LFS integration tests (miss/fetch/
verify, cache hit, corrupted-object rejection, upload refusal).

This is early, single-maintainer software: no independent review or wide
deployment yet. Pin a version and try it against your own setup before you
Expand Down
17 changes: 12 additions & 5 deletions bench/README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
# Benchmark

`run.sh` measures what the proxy saves a fleet of ephemeral clients that clone
the same repo over a slow link. Everything runs on localhost, so it needs no
privileges and no Docker - just `git`, `python3`, and `cargo`.
`run.sh` measures what the proxy saves a fleet of ephemeral clients that fetch
the same content over a slow link - both git clones and git-LFS objects.
Everything runs on localhost, so it needs no privileges and no Docker - just
`git`, `curl`, `python3`, and `cargo`.

```sh
./bench/run.sh
# tunables (env): TOTAL_MB=64 CHUNK_MB=8 RATE_MBIT=20 RTT_MS=60
# tunables (env): TOTAL_MB=64 CHUNK_MB=8 LFS_MB=32 RATE_MBIT=20 RTT_MS=60
```

## What it does
Expand All @@ -20,6 +21,10 @@ privileges and no Docker - just `git`, `python3`, and `cargo`.
- **A, direct** - client clones the origin through the WAN. Every runner pays this today.
- **B, cold proxy** - client clones from the proxy, which fetches the origin through the WAN once (runner 1).
- **C, warm proxy** - client clones from the proxy again; within the fetch TTL it serves from the local mirror, so ~0 bytes cross the WAN (runner 2..N).
4. Then, for git-LFS, points the shim at `lfs_origin.py` (a tiny batch-API + object
server) and fetches one object through the proxy with `curl`:
- **D, cold LFS** - the object is fetched from the origin through the WAN once (runner 1).
- **E, warm LFS** - the object is served from the content-addressed cache, so ~0 bytes cross the WAN (runner 2..N).

## Caveats

Expand All @@ -33,4 +38,6 @@ saving, not a precise WAN emulator:
on the emulation fidelity.
- **Same WAN transport throughout** - the direct clone (A) and the proxy's upstream
fetch (B) both cross the shim over `git://`; the client-to-proxy hop is local HTTP
that never crosses the shim, so the byte comparison is like-for-like.
that never crosses the shim, so the byte comparison is like-for-like. For LFS, the
batch response advertises the object href through the shim too, so the cold fetch
(D) crosses the WAN and the warm fetch (E) does not.
70 changes: 70 additions & 0 deletions bench/lfs_origin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
#!/usr/bin/env python3
"""A minimal git-LFS origin for the benchmark: batch API plus object storage.

Serves two endpoints, enough to drive the proxy's LFS cache:

POST <repo>/info/lfs/objects/batch -> a download action per requested object,
with an href of <advertise-base>/lfs/<oid>. The advertise base is the shim,
so the object download crosses the same emulated WAN as the batch (and is
counted), exactly as a cold client fetch would.
GET /lfs/<oid> -> streams the object bytes.

Anonymous - the benchmark does not exercise auth. Single fixed object, since the
benchmark measures the cold-vs-warm transfer of one object, not fan-out.
"""

import argparse
import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer


def main():
ap = argparse.ArgumentParser()
ap.add_argument("--port", type=int, required=True)
ap.add_argument("--advertise-base", required=True, help="e.g. http://127.0.0.1:<shim-port>")
ap.add_argument("--object-file", required=True)
args = ap.parse_args()

with open(args.object_file, "rb") as f:
blob = f.read()

class Handler(BaseHTTPRequestHandler):
def log_message(self, *_): # keep the benchmark output clean
pass

def do_POST(self):
if not self.path.endswith("/info/lfs/objects/batch"):
self.send_error(404)
return
n = int(self.headers.get("Content-Length", 0))
req = json.loads(self.rfile.read(n) or b"{}")
objects = [
{
"oid": o["oid"],
"size": o["size"],
"actions": {"download": {"href": f"{args.advertise_base}/lfs/{o['oid']}"}},
}
for o in req.get("objects", [])
]
body = json.dumps({"transfer": "basic", "objects": objects}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/vnd.git-lfs+json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)

def do_GET(self):
if "/lfs/" not in self.path:
self.send_error(404)
return
self.send_response(200)
self.send_header("Content-Type", "application/octet-stream")
self.send_header("Content-Length", str(len(blob)))
self.end_headers()
self.wfile.write(blob)

ThreadingHTTPServer(("127.0.0.1", args.port), Handler).serve_forever()


if __name__ == "__main__":
main()
56 changes: 50 additions & 6 deletions bench/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,23 +21,27 @@ ROOT="$(pwd)"

TOTAL_MB="${TOTAL_MB:-64}"
CHUNK_MB="${CHUNK_MB:-8}"
LFS_MB="${LFS_MB:-32}"
RATE_MBIT="${RATE_MBIT:-20}"
RTT_MS="${RTT_MS:-60}"
DAEMON_PORT="${DAEMON_PORT:-9419}"
SHIM_PORT="${SHIM_PORT:-9420}"
PROXY_PORT="${PROXY_PORT:-8899}"
LFS_ORIGIN_PORT="${LFS_ORIGIN_PORT:-9421}"
PROXY_LFS_PORT="${PROXY_LFS_PORT:-8900}"

WORK="$(mktemp -d "${TMPDIR:-/tmp}/git-cache-proxy-bench.XXXXXX")"
ORIGIN="$WORK/origin"
CACHE="$WORK/cache"
CACHE_LFS="$WORK/cache-lfs"
COUNTER="$WORK/counter"
BIN="$ROOT/target/release/git-cache-proxy"

DAEMON_PID="" PROXY_PID="" SHIM_PID=""
DAEMON_PID="" PROXY_PID="" SHIM_PID="" LFS_ORIGIN_PID="" PROXY_LFS_PID=""
cleanup() {
[ -n "$SHIM_PID" ] && kill "$SHIM_PID" 2>/dev/null || true
[ -n "$PROXY_PID" ] && kill "$PROXY_PID" 2>/dev/null || true
[ -n "$DAEMON_PID" ] && kill "$DAEMON_PID" 2>/dev/null || true
for pid in "$SHIM_PID" "$PROXY_LFS_PID" "$LFS_ORIGIN_PID" "$PROXY_PID" "$DAEMON_PID"; do
[ -n "$pid" ] && kill "$pid" 2>/dev/null || true
done
wait 2>/dev/null || true
rm -rf "$WORK"
}
Expand Down Expand Up @@ -74,7 +78,7 @@ sleep 1
start_shim() {
: > "$COUNTER"
python3 "$ROOT/bench/shim.py" --listen-port "$SHIM_PORT" \
--origin-port "$DAEMON_PORT" --rate-mbit "$RATE_MBIT" --rtt-ms "$RTT_MS" \
--origin-port "${1:-$DAEMON_PORT}" --rate-mbit "$RATE_MBIT" --rtt-ms "$RTT_MS" \
--counter-file "$COUNTER" &
SHIM_PID=$!
sleep 1
Expand Down Expand Up @@ -121,10 +125,50 @@ stop_shim
C_TIME=$(elapsed "$t0" "$t1"); C_MB=$(wan_mb)
rm -rf "$WORK/c"

# --- LFS: cold vs warm object fetch through the proxy ---------------------
# The proxy also caches git-LFS objects: the batch API is proxied and the object
# is stored content-addressed. A cold fetch crosses the WAN once; every later
# fetch across the fleet is served locally. Driven with curl against the proxy's
# object endpoint (no git-lfs client needed).
echo ">> creating ${LFS_MB}MB LFS object + origin"
OBJECT="$WORK/object.bin"
dd if=/dev/urandom of="$OBJECT" bs=1048576 count="$LFS_MB" status=none
OID=$(python3 -c "import hashlib,sys; print(hashlib.sha256(open(sys.argv[1],'rb').read()).hexdigest())" "$OBJECT")
OSIZE=$(wc -c < "$OBJECT" | tr -d ' ')

python3 "$ROOT/bench/lfs_origin.py" --port "$LFS_ORIGIN_PORT" \
--advertise-base "http://127.0.0.1:$SHIM_PORT" --object-file "$OBJECT" &
LFS_ORIGIN_PID=$!
sleep 1

"$BIN" --bind "127.0.0.1:$PROXY_LFS_PORT" --cache-root "$CACHE_LFS" \
--upstream "http://127.0.0.1:$SHIM_PORT" --fetch-ttl-seconds 3600 >/dev/null 2>&1 &
PROXY_LFS_PID=$!
for _ in $(seq 1 30); do
curl -fsS "http://127.0.0.1:$PROXY_LFS_PORT/readyz" >/dev/null 2>&1 && break || sleep 0.5
done
LFS_URL="http://127.0.0.1:$PROXY_LFS_PORT/bench.git/info/lfs/objects/$OID?size=$OSIZE"

# --- D: cold LFS object via proxy (runner #1) ----------------------------
echo ">> D: cold LFS object via proxy (runner #1)"
start_shim "$LFS_ORIGIN_PORT"
t0=$(now); curl -fsS -o /dev/null "$LFS_URL" ; t1=$(now)
stop_shim
D_TIME=$(elapsed "$t0" "$t1"); D_MB=$(wan_mb)

# --- E: warm LFS object via proxy (runner #2..N) -------------------------
echo ">> E: warm LFS object via proxy (runner #2..N)"
start_shim "$LFS_ORIGIN_PORT"
t0=$(now); curl -fsS -o /dev/null "$LFS_URL" ; t1=$(now)
stop_shim
E_TIME=$(elapsed "$t0" "$t1"); E_MB=$(wan_mb)

# --- report --------------------------------------------------------------
echo
echo "repo=${TOTAL_MB}MB WAN=${RATE_MBIT}Mbit/s RTT=${RTT_MS}ms"
echo "repo=${TOTAL_MB}MB lfs-obj=${LFS_MB}MB WAN=${RATE_MBIT}Mbit/s RTT=${RTT_MS}ms"
printf '%-28s %10s %12s\n' "scenario" "wall (s)" "WAN (MB)"
printf '%-28s %10s %12s\n' "A direct (per runner)" "$A_TIME" "$A_MB"
printf '%-28s %10s %12s\n' "B cold proxy (runner 1)" "$B_TIME" "$B_MB"
printf '%-28s %10s %12s\n' "C warm proxy (runner 2+)" "$C_TIME" "$C_MB"
printf '%-28s %10s %12s\n' "D cold LFS obj (runner 1)" "$D_TIME" "$D_MB"
printf '%-28s %10s %12s\n' "E warm LFS obj (runner 2+)" "$E_TIME" "$E_MB"
12 changes: 12 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Codecov status gates. Patch coverage must be at least 90%, and project coverage
# must never drop against the base (0% tolerance) - so a change can only hold or
# raise the line. ci.yml enforces a matching floor via `--fail-under-lines`.
coverage:
status:
project:
default:
target: auto
threshold: 0%
patch:
default:
target: 90%
3 changes: 3 additions & 0 deletions deny.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ allow = [
"MIT",
"BSD-3-Clause",
"Unicode-3.0",
# Permissive, OSI-approved. Pulled in by the rustls TLS stack used for the LFS
# HTTPS transfer: ring (Apache-2.0 AND ISC), rustls-webpki, and untrusted.
"ISC",
]
confidence-threshold = 0.9

Expand Down
Loading