Skip to content
Open
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1592,6 +1592,8 @@ The proxy exposes Prometheus metrics at `GET /metrics`. All metric names are pre
| `proxy_metadata_cache_served_bytes_total` | counter | `ecosystem` | Metadata bytes served from the metadata cache. |
| `proxy_metadata_cache_size_bytes` | gauge | `ecosystem` | Size of cached metadata documents per ecosystem. |
| `proxy_metadata_cache_entries` | gauge | `ecosystem` | Number of cached metadata documents per ecosystem. |
| `proxy_git_cache_size_bytes` | gauge | | Size of the cached Git repositories. Git LFS objects are artifacts and count under `proxy_cache_size_bytes`. |
| `proxy_git_cached_repositories` | gauge | | Number of cached Git repositories. |
| `proxy_artifacts_evicted_total` | counter | `reason`, `ecosystem` | Artifacts evicted by the size limit (`lru`) or by age (`retention`) |
| `proxy_metadata_evicted_total` | counter | `ecosystem` | Cached metadata documents evicted by the size limit |
| `proxy_metadata_expired_by_change_feed_total` | counter | `ecosystem` | Cached metadata documents set to revalidate because an upstream [change feed](docs/configuration.md#change-feeds) listed them |
Expand Down
22 changes: 22 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -960,6 +960,28 @@ paths that differ only in case (`acme/widgets` and `acme/WIDGETS`) share one
directory and are treated as one repository. Use a case-sensitive file system
for an upstream whose repositories differ only in case.

The web UI lists the cached repositories at `/ui/git`, linked as "Git" in the
navigation: each one's upstream, clone URL, size, the newest commit of the
branch HEAD names (ID, subject and age), how many branches and tags it has,
when the upstream was last checked and a client last read it, how many
times clients read its refs (each clone, fetch and `git ls-remote` does),
and, while the last refresh failed, the kind of error (such as
`fetch_failed` or `too_large`). A repository the upstream refused shows no
commit, and one the `repos` allowlist no longer allows isn't listed, as `/git`
serves neither. Branches and commits are read with git, one repository at a
time and for at most two seconds per view, and kept until a fetch changes
the repository; the page says how many it hasn't read yet, and each reload
reads more. The request counts and errors are kept in memory and start over
when the proxy restarts. Like the rest of the UI the page has no
authentication, so it shows everyone who can reach it the names, default
branches and newest commit subjects of repositories from upstreams with
credentials. `/git` lets the same people clone them, but where `/ui` and
`/git` sit behind different access rules, the page shows more than `/git`
does. The `proxy_git_cache_size_bytes` and
`proxy_git_cached_repositories` gauges report the git cache's size and
repository count, apart from the artifact gauges, and clones and fetches
count as cache hits or misses under `ecosystem="git"`.

Git LFS objects are cached in the artifact cache, so they use the configured
storage backend and retention rather than `cache_dir`. git-lfs finds the LFS
API under the rewritten remote, at `/git/{name}/{repo}.git/info/lfs`, with no
Expand Down
14 changes: 14 additions & 0 deletions internal/gitcache/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ type Cache struct {
// foldCase is set where the cache directory ignores case, so keys that
// differ only in case, which name one directory, are one repository.
foldCase bool
// stateReads lets one RepositoryState read run git at a time.
stateReads chan struct{}

ctx context.Context
cancel context.CancelFunc
Expand Down Expand Up @@ -142,6 +144,17 @@ type repo struct {
lastUsed atomic.Int64
lastChecked atomic.Int64
usedWritten atomic.Int64
// requests counts the times clients read the refs, and lastError is the
// last failed refresh, nil once one succeeds. Neither is kept across
// restarts.
requests atomic.Int64
lastError atomic.Pointer[refreshError]
// state is the last RepositoryState read, and refsWritten counts the
// times the repository's refs or HEAD were written: by a clone, a fetch,
// a fetch of wanted commits and a change of the default branch. A state
// is current while the count is the one it was read at.
state atomic.Pointer[cachedState]
refsWritten atomic.Int64
}

var errLocked = errors.New("locked by another process")
Expand Down Expand Up @@ -213,6 +226,7 @@ func New(opts Options) (*Cache, error) {
fetches: make(chan struct{}, maxConcurrentFetches),
uploadPacks: make(chan struct{}, uploadPacks),
refListings: make(chan struct{}, refListings),
stateReads: make(chan struct{}, 1),
refCheckTimeout: defaultRefCheckTimeout,
lowSpeedTime: defaultLowSpeedTime,
maxRequestBody: defaultMaxRequestBody,
Expand Down
27 changes: 19 additions & 8 deletions internal/gitcache/refresh.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,9 +151,13 @@ func (c *Cache) startRefresh(rp *repo, repoURL string) *refreshCall {
cancel()
metrics.RecordUpstreamFetch(ecosystem, time.Since(start))
// A refresh cut short by Close is not an upstream failure.
if call.err != nil && c.ctx.Err() == nil {
switch {
case call.err == nil:
rp.lastError.Store(nil)
case c.ctx.Err() == nil:
metrics.RecordUpstreamError(ecosystem, errorType(call.err), metrics.FailureDetail{URL: repoURL, Err: call.err})
c.logger.Warn("git cache: refresh failed", "repository", rp.key, "error", call.err)
rp.lastError.Store(&refreshError{kind: errorType(call.err), at: time.Now()})
}
c.mu.Lock()
rp.refreshing = nil
Expand Down Expand Up @@ -223,7 +227,7 @@ func (c *Cache) clone(ctx context.Context, rp *repo, repoURL string, remote remo
if err := c.fetch(ctx, tmp, repoURL, c.opts.MaxRepoSize, res, refreshFlags, branchesAndTags...); err != nil {
return err
}
if err := c.setHead(ctx, tmp, remote, ""); err != nil {
if _, err := c.setHead(ctx, tmp, remote, ""); err != nil {
return err
}
size := dirSize(tmp)
Expand All @@ -236,6 +240,7 @@ func (c *Cache) clone(ctx context.Context, rp *repo, repoURL string, remote remo
if err := os.Rename(tmp, rp.dir); err != nil {
return fmt.Errorf("moving clone into place: %w", err)
}
rp.refsWritten.Add(1)
res.settle(rp, size)
c.logger.Info("git cache: cloned repository", "repository", rp.key, "size", size)
return nil
Expand All @@ -262,6 +267,7 @@ func (c *Cache) update(ctx context.Context, rp *repo, repoURL string, remote rem
}
res := c.newReservation()
err = c.fetch(ctx, rp.dir, repoURL, limit, res, refreshFlags, branchesAndTags...)
rp.refsWritten.Add(1)
res.settle(rp, dirSize(rp.dir))
if err != nil {
return err
Expand All @@ -271,7 +277,11 @@ func (c *Cache) update(ctx context.Context, rp *repo, repoURL string, remote rem
c.logger.Warn("git cache: pruning wanted refs failed", "repository", rp.key, "error", err)
}
head, _ := c.run(ctx, rp.dir, "symbolic-ref", "--quiet", "HEAD")
return c.setHead(ctx, rp.dir, remote, strings.TrimSpace(head))
moved, err := c.setHead(ctx, rp.dir, remote, strings.TrimSpace(head))
if moved {
rp.refsWritten.Add(1)
}
return err
}

// branchesAndTags are the refspecs of a refresh. A bare clone has none of its
Expand Down Expand Up @@ -336,16 +346,17 @@ func (c *Cache) fetch(ctx context.Context, dir, repoURL string, limit int64, res
}

// setHead points HEAD at the upstream's default branch, when the upstream
// names one and it is among the cached branches.
func (c *Cache) setHead(ctx context.Context, dir string, remote remoteRefs, current string) error {
// names one and it is among the cached branches. It reports whether it
// wrote HEAD.
func (c *Cache) setHead(ctx context.Context, dir string, remote remoteRefs, current string) (bool, error) {
if remote.head == "" || remote.head == current {
return nil
return false, nil
}
if _, ok := remote.refs[remote.head]; !ok {
return nil
return false, nil
}
_, err := c.run(ctx, dir, "symbolic-ref", "HEAD", remote.head)
return err
return err == nil, err
}

// localRefs lists the cached branches and tags, and the names of the wanted
Expand Down
3 changes: 3 additions & 0 deletions internal/gitcache/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ func (c *Cache) Open(ctx context.Context, key, repoURL string, opts OpenOptions)
}
rp := c.acquire(key)
hit := rp.exists()
if opts.Refresh {
rp.requests.Add(1)
}
var err error
switch {
case opts.Refresh || !hit:
Expand Down
229 changes: 229 additions & 0 deletions internal/gitcache/stats.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
package gitcache

import (
"context"
"errors"
"fmt"
"os/exec"
"path/filepath"
"slices"
"strconv"
"strings"
"time"
)

// RepositoryStats describes a cached repository.
type RepositoryStats struct {
// Key is the repository's path in the cache, such as
// github-1a2b3c4d/acme/widgets.git. It is spelled as the directory is,
// also where the cache folds keys, so it keeps the case of the clone
// that created it.
Key string
Size int64
// LastChecked is when the upstream's refs were last compared, and
// LastUsed when a client last read the repository.
LastChecked, LastUsed time.Time
// Requests counts the times clients read the repository's refs, which
// each clone, fetch and ls-remote does, since the cache was opened.
Requests int64
// LastError names the kind of error the last refresh failed with, such
// as fetch_failed or too_large, and LastErrorAt when. Both are empty
// once a refresh succeeds.
LastError string
LastErrorAt time.Time
// Refused is set while the upstream's last answer refused the
// repository or reported it missing, so it isn't served.
Refused bool
}

// refreshError is the last failed refresh of a repository.
type refreshError struct {
kind string
at time.Time
}

// Repositories describes the repositories in the cache, sorted by key.
func (c *Cache) Repositories() []RepositoryStats {
c.mu.Lock()
repos := make([]*repo, 0, len(c.repos))
refused := make(map[*repo]bool)
for _, rp := range c.repos {
repos = append(repos, rp)
refused[rp] = rp.refused != nil
}
c.mu.Unlock()

var out []RepositoryStats
for _, rp := range repos {
if !rp.exists() {
continue
}
s := RepositoryStats{
Key: c.dirKey(rp),
Size: rp.size.Load(),
LastChecked: unixNanoTime(rp.lastChecked.Load()),
LastUsed: unixNanoTime(rp.lastUsed.Load()),
Requests: rp.requests.Load(),
Refused: refused[rp],
}
if e := rp.lastError.Load(); e != nil {
s.LastError, s.LastErrorAt = e.kind, e.at
}
out = append(out, s)
}
slices.SortFunc(out, func(a, b RepositoryStats) int { return strings.Compare(a.Key, b.Key) })
return out
}

// dirKey is the key of rp spelled as its directory is, which rp.key is not
// where keys are folded.
func (c *Cache) dirKey(rp *repo) string {
rel, err := filepath.Rel(c.dir, rp.dir)
if err != nil {
return rp.key
}
return filepath.ToSlash(rel)
}

func unixNanoTime(n int64) time.Time {
if n == 0 {
return time.Time{}
}
return time.Unix(0, n)
}

// RepositoryState is what a cached repository holds: the branch HEAD names
// with its newest commit, and how many branches and tags there are.
type RepositoryState struct {
Branch string
Commit string
CommitTime time.Time
// Subject is the first line of the commit message, at most
// maxSubjectLength bytes.
Subject string
Branches, Tags int
}

// maxSubjectLength bounds RepositoryState.Subject.
const maxSubjectLength = 200

// headFields are the commit ID, time and subject readState asks git log for.
const headFields = 3

// stateRetryDelay is how long a failed state read is kept.
const stateRetryDelay = time.Minute

// cachedState is a repository's state, or the error reading it failed
// with, as read when the repository's refs had been written written times.
// A failed read is kept until retryAt.
type cachedState struct {
written int64
state RepositoryState
err error
retryAt time.Time
}

// RepositoryState reads the state of the repository cached under key. It is
// read again only once the repository's refs or HEAD were written, not after
// a check of the upstream that found nothing new, so a page listing many
// repositories runs git for those that changed. A read that failed is kept
// for stateRetryDelay.
//
// One read runs at a time, however many callers ask, so listing a large
// cache doesn't start git processes without bound. A caller whose ctx ends
// before its read is done gets ctx's error; a state already read is
// returned without waiting.
func (c *Cache) RepositoryState(ctx context.Context, key string) (RepositoryState, error) {
c.mu.Lock()
rp := c.repos[c.repoKey(key)]
c.mu.Unlock()
if rp == nil || !rp.exists() {
return RepositoryState{}, ErrNotFound
}
if cached := rp.knownState(); cached != nil {
return cached.state, cached.err
}
if err := ctx.Err(); err != nil {
return RepositoryState{}, err
}
select {
case c.stateReads <- struct{}{}:
defer func() { <-c.stateReads }()
case <-ctx.Done():
return RepositoryState{}, ctx.Err()
}
// The read this one waited for may have been of the same repository.
if cached := rp.knownState(); cached != nil {
return cached.state, cached.err
}
// Counted before the read: a write during it leaves this state stale,
// and it is then read again.
written := rp.refsWritten.Load()
state, err := c.readState(ctx, rp.dir)
if err != nil && ctx.Err() != nil {
// A read the caller gave up on says nothing about the repository.
return RepositoryState{}, ctx.Err()
}
rp.state.Store(&cachedState{written: written, state: state, err: err, retryAt: time.Now().Add(stateRetryDelay)})
return state, err
}

// knownState is the state read since rp's refs were last written, or nil if
// it has to be read.
func (rp *repo) knownState() *cachedState {
cached := rp.state.Load()
if cached == nil || cached.written != rp.refsWritten.Load() ||
(cached.err != nil && !time.Now().Before(cached.retryAt)) {
return nil
}
return cached
}

func (c *Cache) readState(ctx context.Context, dir string) (RepositoryState, error) {
var state RepositoryState
refs, err := c.run(ctx, dir, "for-each-ref", "--format=%(refname)", "refs/heads", "refs/tags")
if err != nil {
return state, err
}
for ref := range strings.Lines(refs) {
if strings.HasPrefix(ref, "refs/heads/") {
state.Branches++
} else {
state.Tags++
}
}
// Both exit 1, and print nothing, for a detached HEAD and for one with
// no commit yet; any other failure is an error.
branch, err := c.run(ctx, dir, "symbolic-ref", "--quiet", "--short", "HEAD")
if err != nil && !exitedWith(err, 1) {
return state, err
}
state.Branch = strings.TrimSpace(branch)
if _, err := c.run(ctx, dir, "rev-parse", "--verify", "--quiet", "HEAD"); exitedWith(err, 1) {
return state, nil
} else if err != nil {
return state, err
}
head, err := c.run(ctx, dir, "log", "-1", "--format=%H%x00%ct%x00%s", "HEAD", "--")
if err != nil {
return state, err
}
fields := strings.SplitN(strings.TrimSpace(head), "\x00", headFields)
if len(fields) != headFields {
return state, fmt.Errorf("git log: unexpected output %q", head)
}
state.Commit, state.Subject = fields[0], fields[2]
if unix, err := strconv.ParseInt(fields[1], 10, 64); err == nil {
state.CommitTime = time.Unix(unix, 0)
}
if len(state.Subject) > maxSubjectLength {
state.Subject = strings.ToValidUTF8(state.Subject[:maxSubjectLength], "") + "…"
}
return state, nil
}

// exitedWith reports whether err is from a git process that exited with code.
func exitedWith(err error, code int) bool {
var exit *exec.ExitError
return errors.As(err, &exit) && exit.ExitCode() == code
}
Loading
Loading