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 @@ -174,6 +174,7 @@ Every flag has an environment-variable equivalent.
| `--max-decoded-body-mb` | `GITCACHEPROXY_MAX_DECODED_BODY_MB` | `512` | Cap on a decoded upload-pack request body, in MiB (bounds memory / gzip bombs) |
| `--cache-max-mb` | `GITCACHEPROXY_CACHE_MAX_MB` | `0` | Cap on total on-disk mirror cache, in MiB; evicts least-recently-used idle mirrors when exceeded (`0` = unlimited, no eviction) |
| `--git-binary` | `GITCACHEPROXY_GIT_BINARY` | `git` | Path to git |
| `--big-file-threshold` | `GITCACHEPROXY_BIG_FILE_THRESHOLD` | `8m` | Stream blobs larger than this to disk during upstream clone/fetch instead of holding them in memory, bounding `index-pack` RSS so one very large repo can't OOM the proxy |

Endpoints: `/healthz`, `/readyz`, `/metrics` (Prometheus - per-repo request and
upstream counters, cache-size gauges, LFS object hit/miss counters, and
Expand Down
8 changes: 8 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,14 @@ pub struct Config {
#[arg(long, env = "GITCACHEPROXY_GIT_BINARY", default_value = "git")]
pub git_binary: String,

/// Cap the memory an upstream `git clone`/`fetch` uses to mirror a repo: blobs
/// larger than this are streamed to disk instead of held in RAM for delta
/// resolution, so a single very large repo can't OOM the proxy. Passed to git as
/// `core.bigFileThreshold` (git's own default is 512m). Accepts git size units
/// (`8m`, `512k`, `1g`).
#[arg(long, env = "GITCACHEPROXY_BIG_FILE_THRESHOLD", default_value = "8m")]
pub big_file_threshold: String,

/// Log filter directive (e.g. `info`, `git_cache_proxy=debug,tower=warn`).
/// Overridden by the `RUST_LOG` environment variable when set.
#[arg(long, env = "GITCACHEPROXY_LOG", default_value = "info")]
Expand Down
1 change: 1 addition & 0 deletions src/evict.rs
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,7 @@ mod tests {
GitConfig {
git_binary: "git".into(),
upstream_auth_header: None,
big_file_threshold: "8m".into(),
fetch_ttl: Duration::from_secs(10),
}
}
Expand Down
56 changes: 52 additions & 4 deletions src/git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ pub struct GitConfig {
/// Optional header for upstream auth, e.g. `Authorization: Bearer <token>`.
/// Injected via env (not argv) so it never shows up in `ps`. Never logged.
pub upstream_auth_header: Option<String>,
/// git `core.bigFileThreshold` for upstream clone/fetch: blobs above it are
/// streamed to disk rather than held in memory, bounding `index-pack` RSS on
/// very large repos. See `Config::big_file_threshold`.
pub big_file_threshold: String,
/// Skip the upstream fetch if the mirror was refreshed within this window.
pub fetch_ttl: Duration,
}
Expand Down Expand Up @@ -357,10 +361,11 @@ impl GitCache {
fn fetch_cmd(&self) -> Command {
let mut c = Command::new(&self.cfg.git_binary);
c.env("GIT_TERMINAL_PROMPT", "0"); // fail instead of hanging on a prompt
if let Some(h) = &self.cfg.upstream_auth_header {
c.env("GIT_CONFIG_COUNT", "1")
.env("GIT_CONFIG_KEY_0", "http.extraHeader")
.env("GIT_CONFIG_VALUE_0", h);
for (k, v) in git_config_env(
&self.cfg.big_file_threshold,
self.cfg.upstream_auth_header.as_deref(),
) {
c.env(k, v);
}
c
}
Expand Down Expand Up @@ -425,6 +430,29 @@ impl<R> Drop for TimedReader<R> {
}
}

/// Assemble the `GIT_CONFIG_*` environment for an upstream git command: the
/// memory-bounding options, then the optional auth header, numbered as git's
/// env-based config protocol requires (`GIT_CONFIG_COUNT` + `KEY_i`/`VALUE_i`).
/// Everything goes via env, not argv, so the auth header never shows up in `ps`.
/// `core.bigFileThreshold` streams large blobs to disk instead of holding them in
/// memory, and `core.deltaBaseCacheLimit` caps index-pack's delta-base cache - so a
/// single very large repo's clone cannot balloon RSS and OOM the proxy.
fn git_config_env(big_file_threshold: &str, auth_header: Option<&str>) -> Vec<(String, String)> {
let mut pairs: Vec<(&str, &str)> = vec![
("core.bigFileThreshold", big_file_threshold),
("core.deltaBaseCacheLimit", "128m"),
];
if let Some(h) = auth_header {
pairs.push(("http.extraHeader", h));
}
let mut env = vec![("GIT_CONFIG_COUNT".to_string(), pairs.len().to_string())];
for (i, (k, v)) in pairs.into_iter().enumerate() {
env.push((format!("GIT_CONFIG_KEY_{i}"), k.to_string()));
env.push((format!("GIT_CONFIG_VALUE_{i}"), v.to_string()));
}
env
}

/// Encode a string as a single pkt-line (4-hex length prefix + payload).
fn pkt_line(s: &str) -> Vec<u8> {
let mut v = format!("{:04x}", s.len() + 4).into_bytes();
Expand All @@ -436,6 +464,26 @@ fn pkt_line(s: &str) -> Vec<u8> {
mod tests {
use super::*;

#[test]
fn git_config_env_numbers_options_and_appends_auth() {
// No auth: just the two memory bounds, numbered from 0.
let env = git_config_env("8m", None);
assert!(env.contains(&("GIT_CONFIG_COUNT".into(), "2".into())));
assert!(env.contains(&("GIT_CONFIG_KEY_0".into(), "core.bigFileThreshold".into())));
assert!(env.contains(&("GIT_CONFIG_VALUE_0".into(), "8m".into())));
assert!(env.contains(&("GIT_CONFIG_KEY_1".into(), "core.deltaBaseCacheLimit".into())));

// With auth: appended as the last numbered entry, count bumps to 3.
let env = git_config_env("16m", Some("Authorization: Basic xyz"));
assert!(env.contains(&("GIT_CONFIG_COUNT".into(), "3".into())));
assert!(env.contains(&("GIT_CONFIG_VALUE_0".into(), "16m".into())));
assert!(env.contains(&("GIT_CONFIG_KEY_2".into(), "http.extraHeader".into())));
assert!(env.contains(&(
"GIT_CONFIG_VALUE_2".into(),
"Authorization: Basic xyz".into()
)));
}

#[test]
fn pkt_line_encodes_length() {
assert_eq!(pkt_line("a"), b"0005a");
Expand Down
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ async fn main() -> Result<()> {
let git_cfg = git::GitConfig {
git_binary: cfg.git_binary.clone(),
upstream_auth_header: upstream_auth_header.clone(),
big_file_threshold: cfg.big_file_threshold.clone(),
fetch_ttl: Duration::from_secs(cfg.fetch_ttl_seconds),
};

Expand Down
2 changes: 2 additions & 0 deletions tests/e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ async fn upload_pack_decodes_gzip_encoded_request() {
let cfg = GitConfig {
git_binary: "git".into(),
upstream_auth_header: None,
big_file_threshold: "8m".into(),
fetch_ttl: Duration::from_secs(0),
};
let state = AppState {
Expand Down Expand Up @@ -176,6 +177,7 @@ async fn clones_through_proxy_serves_all_refs_and_rejects_push() {
let cfg = GitConfig {
git_binary: "git".into(),
upstream_auth_header: None,
big_file_threshold: "8m".into(),
fetch_ttl: Duration::from_secs(0),
};
// Eviction enabled with an effectively unbounded cap: no mirror is ever
Expand Down
2 changes: 2 additions & 0 deletions tests/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ fn state(serve_token: Option<String>) -> AppState {
let cfg = GitConfig {
git_binary: "git".into(),
upstream_auth_header: None,
big_file_threshold: "8m".into(),
fetch_ttl: Duration::from_secs(10),
};
let lfs = Arc::new(Lfs::new(
Expand Down Expand Up @@ -211,6 +212,7 @@ async fn upstream_failure_returns_bad_gateway_and_records_error() {
let cfg = GitConfig {
git_binary: "git".into(),
upstream_auth_header: None,
big_file_threshold: "8m".into(),
fetch_ttl: Duration::from_secs(10),
};
let lfs = Arc::new(Lfs::new(
Expand Down
1 change: 1 addition & 0 deletions tests/lfs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,7 @@ fn proxy_state(addr: SocketAddr, cache: &std::path::Path, metrics: Arc<Metrics>)
let cfg = GitConfig {
git_binary: "git".into(),
upstream_auth_header: None,
big_file_threshold: "8m".into(),
fetch_ttl: Duration::from_secs(10),
};
AppState {
Expand Down