use std::collections::HashMap;
use std::path::Path;
use std::pin::Pin;
use std::process::Stdio;
use std::sync::Arc;
use std::task::Poll;
use std::time::{Duration, Instant};
use anyhow::{Context, Result, bail};
use bytes::Bytes;
use tokio::io::{AsyncRead, AsyncWriteExt, ReadBuf};
use tokio::process::{ChildStdout, Command};
use tokio::sync::Mutex;
use tokio_util::io::ReaderStream;
use crate::metrics::{Metrics, ServeKind, Status, UpstreamOp};
use crate::repo::RepoRef;
#[derive(Debug, Clone, Copy)]
pub enum CacheOutcome {
Cloned,
Fetched,
Cached,
}
#[derive(Clone)]
pub struct GitConfig {
pub git_binary: String,
pub upstream_auth_header: Option<String>,
pub fetch_ttl: Duration,
}
struct RepoSlot {
fetch_lock: Mutex<Option<Instant>>,
}
pub struct GitCache {
cfg: GitConfig,
metrics: Arc<Metrics>,
slots: Mutex<HashMap<String, Arc<RepoSlot>>>,
index: Option<Arc<crate::evict::CacheIndex>>,
}
impl GitCache {
pub fn new(
cfg: GitConfig,
metrics: Arc<Metrics>,
index: Option<Arc<crate::evict::CacheIndex>>,
) -> Self {
Self {
cfg,
metrics,
slots: Mutex::new(HashMap::new()),
index,
}
}
async fn slot(&self, name: &str) -> Arc<RepoSlot> {
self.slots
.lock()
.await
.entry(name.to_string())
.or_insert_with(|| {
Arc::new(RepoSlot {
fetch_lock: Mutex::new(None),
})
})
.clone()
}
fn mark_changed(&self, repo: &RepoRef) {
if let Some(idx) = &self.index {
idx.mark_changed(&repo.name);
}
}
pub async fn evict(&self, name: &str, cache_dir: &Path) -> Result<()> {
let slot = self.slot(name).await;
let guard = slot.fetch_lock.lock().await;
if !cache_dir.join("HEAD").exists() {
return Ok(()); }
let mut trash = cache_dir.as_os_str().to_owned();
trash.push(crate::repo::EVICTING_SUFFIX);
let trash = std::path::PathBuf::from(trash);
let _ = tokio::fs::remove_dir_all(&trash).await;
tokio::fs::rename(cache_dir, &trash)
.await
.with_context(|| format!("rename mirror for eviction: {name}"))?;
drop(guard); let _ = tokio::fs::remove_dir_all(&trash).await;
Ok(())
}
pub async fn ensure_fresh(&self, repo: &RepoRef, want_fetch: bool) -> Result<CacheOutcome> {
let slot = self.slot(&repo.name).await;
if let Some(idx) = &self.index {
idx.touch(&repo.name);
}
let mut last = slot.fetch_lock.lock().await;
if !repo.cache_dir.join("HEAD").exists() {
self.clone_mirror(repo).await?;
*last = Some(Instant::now());
return Ok(CacheOutcome::Cloned);
}
if want_fetch {
let stale = match *last {
None => true,
Some(t) => self.cfg.fetch_ttl.is_zero() || t.elapsed() >= self.cfg.fetch_ttl,
};
if stale {
self.fetch(repo).await?;
*last = Some(Instant::now());
return Ok(CacheOutcome::Fetched);
}
}
Ok(CacheOutcome::Cached)
}
pub async fn advertise_refs(
&self,
repo: &RepoRef,
git_protocol: Option<&str>,
) -> Result<Bytes> {
let mut cmd = self.local_cmd(git_protocol);
cmd.arg("upload-pack")
.arg("--stateless-rpc")
.arg("--advertise-refs")
.arg(&repo.cache_dir);
let started = Instant::now();
let out = cmd
.output()
.await
.context("spawn git upload-pack --advertise-refs")?;
if !out.status.success() {
bail!(
"advertise-refs failed for {}: {}",
repo.name,
String::from_utf8_lossy(&out.stderr)
);
}
self.metrics.observe_serve(
ServeKind::InfoRefs,
&repo.name,
started.elapsed().as_secs_f64(),
);
let mut body = pkt_line("# service=git-upload-pack\n");
body.extend_from_slice(b"0000"); body.extend_from_slice(&out.stdout);
Ok(Bytes::from(body))
}
pub async fn upload_pack_rpc(
&self,
repo: &RepoRef,
git_protocol: Option<&str>,
body: Bytes,
) -> Result<ReaderStream<TimedReader<ChildStdout>>> {
let started = Instant::now();
let mut cmd = self.local_cmd(git_protocol);
cmd.arg("upload-pack")
.arg("--stateless-rpc")
.arg(&repo.cache_dir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit());
let mut child = cmd.spawn().context("spawn git upload-pack")?;
let mut stdin = child.stdin.take().context("upload-pack: no stdin")?;
let stdout = child.stdout.take().context("upload-pack: no stdout")?;
stdin
.write_all(&body)
.await
.context("write upload-pack request")?;
drop(stdin);
let name = repo.name.clone();
tokio::spawn(async move {
match child.wait().await {
Ok(s) if !s.success() => {
tracing::warn!(repo = %name, "git upload-pack exited: {s}")
}
Err(e) => tracing::warn!(repo = %name, "git upload-pack wait failed: {e}"),
_ => {}
}
});
let timed = TimedReader {
inner: stdout,
repo: repo.name.clone(),
recorder: Some((self.metrics.clone(), started)),
};
Ok(ReaderStream::new(timed))
}
async fn clone_mirror(&self, repo: &RepoRef) -> Result<()> {
if let Some(parent) = repo.cache_dir.parent() {
tokio::fs::create_dir_all(parent)
.await
.with_context(|| format!("create cache parent for {}", repo.name))?;
}
let mut tmp = repo.cache_dir.clone().into_os_string();
tmp.push(crate::repo::INCOMING_SUFFIX);
let tmp = std::path::PathBuf::from(tmp);
let _ = tokio::fs::remove_dir_all(&tmp).await;
tracing::info!(repo = %repo.name, "cloning mirror from upstream");
let started = Instant::now();
let status = self
.fetch_cmd()
.arg("clone")
.arg("--mirror")
.arg("--quiet")
.arg(&repo.upstream_url)
.arg(&tmp)
.status()
.await
.context("spawn git clone --mirror")?;
if !status.success() {
let _ = tokio::fs::remove_dir_all(&tmp).await;
self.metrics
.record_upstream(UpstreamOp::Clone, Status::Error, "-");
bail!("git clone --mirror failed for {}", repo.name);
}
let elapsed = started.elapsed().as_secs_f64();
tokio::fs::rename(&tmp, &repo.cache_dir)
.await
.context("rename mirror into place")?;
self.metrics
.record_upstream(UpstreamOp::Clone, Status::Ok, &repo.name);
self.metrics
.observe_upstream(UpstreamOp::Clone, &repo.name, elapsed);
self.mark_changed(repo);
Ok(())
}
async fn fetch(&self, repo: &RepoRef) -> Result<()> {
tracing::debug!(repo = %repo.name, "fetching updates from upstream");
let started = Instant::now();
let status = self
.fetch_cmd()
.current_dir(&repo.cache_dir)
.arg("fetch")
.arg("--prune")
.arg("--quiet")
.arg("origin")
.status()
.await
.context("spawn git fetch")?;
if !status.success() {
self.metrics
.record_upstream(UpstreamOp::Fetch, Status::Error, "-");
bail!("git fetch failed for {}", repo.name);
}
self.metrics
.record_upstream(UpstreamOp::Fetch, Status::Ok, &repo.name);
self.metrics.observe_upstream(
UpstreamOp::Fetch,
&repo.name,
started.elapsed().as_secs_f64(),
);
self.mark_changed(repo);
Ok(())
}
fn fetch_cmd(&self) -> Command {
let mut c = Command::new(&self.cfg.git_binary);
c.env("GIT_TERMINAL_PROMPT", "0"); 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);
}
c
}
fn local_cmd(&self, git_protocol: Option<&str>) -> Command {
let mut c = Command::new(&self.cfg.git_binary);
if let Some(p) = git_protocol {
c.env("GIT_PROTOCOL", p);
}
c
}
}
pub struct TimedReader<R> {
inner: R,
repo: String,
recorder: Option<(Arc<Metrics>, Instant)>,
}
impl<R> TimedReader<R> {
fn record(&mut self) {
if let Some((metrics, started)) = self.recorder.take() {
metrics.observe_serve(
ServeKind::UploadPack,
&self.repo,
started.elapsed().as_secs_f64(),
);
}
}
}
impl<R: AsyncRead + Unpin> AsyncRead for TimedReader<R> {
fn poll_read(
self: Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<std::io::Result<()>> {
let this = self.get_mut();
let before = buf.filled().len();
let poll = Pin::new(&mut this.inner).poll_read(cx, buf);
if let Poll::Ready(Ok(())) = &poll
&& buf.filled().len() == before
{
this.record();
}
poll
}
}
impl<R> Drop for TimedReader<R> {
fn drop(&mut self) {
self.record();
}
}
fn pkt_line(s: &str) -> Vec<u8> {
let mut v = format!("{:04x}", s.len() + 4).into_bytes();
v.extend_from_slice(s.as_bytes());
v
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pkt_line_encodes_length() {
assert_eq!(pkt_line("a"), b"0005a");
assert_eq!(&pkt_line("# service=git-upload-pack\n")[..4], b"001e");
}
#[tokio::test]
async fn timed_reader_records_serve_duration_at_eof() {
use tokio::io::AsyncReadExt;
let metrics = Arc::new(Metrics::new());
let mut reader = TimedReader {
inner: &b"packfile bytes"[..],
repo: "group/foo.git".into(),
recorder: Some((metrics.clone(), Instant::now())),
};
let mut sink = Vec::new();
reader.read_to_end(&mut sink).await.unwrap();
assert!(metrics.gather().contains(
r#"gitcacheproxy_serve_duration_seconds_count{kind="upload_pack",repo="group/foo.git"} 1"#
));
}
}