Cache missing branches, bound and dedupe vcs branch lookups, map rate limits to 503
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful

This commit is contained in:
2026-10-24 16:49:02 +11:00
parent d15364cbd6
commit cff459abda
4 changed files with 267 additions and 73 deletions
+3 -3
View File
@@ -2,7 +2,7 @@
Shared classification, TTL and eviction rules: [caching](../common/caching.md).
Caches source archives and release assets from GitHub (`base_url = "https://github.com"`) or any Gitea (any other `base_url`). Only the paths below are served; anything else is a 404.
Caches source archives and release assets from GitHub (`base_url = "https://github.com"`) or Gitea (any other `base_url`). GitHub Enterprise is not supported: a non-github.com host is treated as Gitea. Only the paths below are served; anything else is a 404.
| Path | Behaviour |
|---|---|
@@ -10,9 +10,9 @@ Caches source archives and release assets from GitHub (`base_url = "https://gith
| `<owner>/<repo>/archive/<40-char sha>.(tar.gz\|zip)` | immutable |
| `<owner>/<repo>/releases/download/<tag>/<asset>` | immutable |
| `<owner>/<repo>/releases/latest/download/<asset>` | mutable |
| `<owner>/<repo>/archive/refs/heads/<branch>.(tar.gz\|zip)` | `302` to the archive of the branch's current commit, resolved via the provider API and reused for `mutable_ttl` |
| `<owner>/<repo>/archive/refs/heads/<branch>.(tar.gz\|zip)` | `302` to the archive of the branch's current commit, resolved via the provider's branch API and reused for `mutable_ttl` |
Branch archives redirect, so `curl` needs `-L`; pip and npm follow redirects. The archive is the host's commit archive (GitHub names its top-level directory `<repo>-<sha>`). `patterns` and `blocklist` match both the branch path and the commit path it redirects to, so allowlist repos by prefix (`^cli/cli/`). GitHub API calls use the [server GitHub credential](../../README.md#github-authentication); Gitea API calls use the remote's `username`/`password`.
Branch archives redirect, so `curl` needs `-L`; pip and npm follow redirects. The archive is the host's commit archive (GitHub names its top-level directory `<repo>-<sha>`). `patterns` and `blocklist` match both the branch path and the commit path it redirects to, so allowlist repos by prefix (`^cli/cli/`); a branch whose commit path is denied is a `403`. A missing branch is a `404`, cached for `mutable_ttl` (at least 60s). An API rate limit (`429`, or `403` with rate-limit headers) is a `503` with `Retry-After`, unless `stale_on_error` serves the last known commit. Each remote caches up to 4096 branch lookups. GitHub API calls use the [server GitHub credential](../../README.md#github-authentication); Gitea API calls use the remote's `username`/`password`.
```hcl
resource "artifactapi_remote_vcs" "github" {
+3 -3
View File
@@ -71,7 +71,7 @@ func (p *Provider) AuthHeaders(ctx context.Context, remote models.Remote) (http.
h.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(remote.Username+":"+remote.Password)))
return h, nil
}
if isGitHubHost(remote.BaseURL) {
if IsGitHubHost(remote.BaseURL) {
if c := githubauth.Server(); c != nil {
tok, err := c.Token(ctx)
if err != nil {
@@ -85,11 +85,11 @@ func (p *Provider) AuthHeaders(ctx context.Context, remote models.Remote) (http.
return h, nil
}
// isGitHubHost reports whether rawURL targets a GitHub API/download host that
// IsGitHubHost reports whether rawURL targets a GitHub API/download host that
// accepts the server credential. objects.githubusercontent.com is deliberately
// excluded: release-asset downloads 302-redirect there with a pre-signed URL
// that must not carry an Authorization header.
func isGitHubHost(rawURL string) bool {
func IsGitHubHost(rawURL string) bool {
u, err := url.Parse(rawURL)
if err != nil {
return false
+89 -43
View File
@@ -12,10 +12,13 @@ import (
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"sync"
"time"
"golang.org/x/sync/singleflight"
"git.unkin.net/unkin/artifactapi/internal/provider"
"git.unkin.net/unkin/artifactapi/internal/provider/generic"
"git.unkin.net/unkin/artifactapi/internal/proxy"
@@ -51,6 +54,11 @@ var (
var errNotFound = errors.New("ref not found")
const (
maxRefs = 4096
negativeTTL = time.Minute
)
// Provider embeds generic for upstream URLs, content types and GitHub/Basic auth.
type Provider struct {
generic.Provider
@@ -58,10 +66,12 @@ type Provider struct {
client *http.Client
githubAPI string
mu sync.Mutex
refs map[string]resolved
group singleflight.Group
mu sync.Mutex
refs map[string]map[string]resolved
}
// resolved is a branch lookup; an empty commit records a missing branch.
type resolved struct {
commit string
at time.Time
@@ -71,7 +81,7 @@ func New() *Provider {
return &Provider{
client: &http.Client{Timeout: 30 * time.Second},
githubAPI: "https://api.github.com",
refs: map[string]resolved{},
refs: map[string]map[string]resolved{},
}
}
@@ -159,46 +169,88 @@ func (p *Provider) ServeRemote(w http.ResponseWriter, r *http.Request, remote mo
if ref.Kind != branchArchive {
return false
}
if proxy.NewClassifier(p).Classify(remote, path) == proxy.ClassDenied {
classifier := proxy.NewClassifier(p)
if classifier.Classify(remote, path) == proxy.ClassDenied {
http.Error(w, "access denied", http.StatusForbidden)
return true
}
commit, err := p.resolve(r.Context(), remote, ref)
if errors.Is(err, errNotFound) {
var se *provider.UpstreamStatusError
switch {
case errors.Is(err, errNotFound):
http.Error(w, "branch not found", http.StatusNotFound)
return true
}
if err != nil {
case errors.As(err, &se) && (se.Status == http.StatusTooManyRequests || se.Status == http.StatusForbidden && !se.RetryAt.IsZero()):
secs := 60
if !se.RetryAt.IsZero() {
secs = max(1, int(time.Until(se.RetryAt).Seconds()+1))
}
w.Header().Set("Retry-After", strconv.Itoa(secs))
http.Error(w, "upstream rate limited", http.StatusServiceUnavailable)
return true
case err != nil:
http.Error(w, "bad gateway", http.StatusBadGateway)
return true
}
loc := strings.TrimRight(proxyBaseURL, "/") + provider.RemoteMountPath + remote.Name + "/" +
ref.Owner + "/" + ref.Repo + "/archive/" + commit + ref.Ext
http.Redirect(w, r, loc, http.StatusFound)
target := ref.Owner + "/" + ref.Repo + "/archive/" + commit + ref.Ext
if classifier.Classify(remote, target) == proxy.ClassDenied {
http.Error(w, "access denied", http.StatusForbidden)
return true
}
http.Redirect(w, r, strings.TrimRight(proxyBaseURL, "/")+provider.RemoteMountPath+remote.Name+"/"+target, http.StatusFound)
return true
}
// resolve maps a branch to its commit, reusing the answer for mutable_ttl and,
// with stale_on_error, past an upstream failure.
// resolve maps a branch to its commit, reusing the answer for mutable_ttl (a
// missing branch for at least negativeTTL) and, with stale_on_error, past an
// upstream failure. Concurrent lookups of one branch share a single API call.
func (p *Provider) resolve(ctx context.Context, remote models.Remote, ref target) (string, error) {
key := remote.Name + "\x00" + ref.Owner + "/" + ref.Repo + "\x00" + ref.Name
key := ref.Owner + "/" + ref.Repo + "\x00" + ref.Name
ttl := time.Duration(remote.MutableTTL) * time.Second
p.mu.Lock()
prev, ok := p.refs[key]
prev, ok := p.refs[remote.Name][key]
p.mu.Unlock()
if ok && time.Since(prev.at) < time.Duration(remote.MutableTTL)*time.Second {
if ok && prev.commit != "" && time.Since(prev.at) < ttl {
return prev.commit, nil
}
commit, err := p.lookup(ctx, remote, ref)
if ok && prev.commit == "" && time.Since(prev.at) < max(ttl, negativeTTL) {
return "", errNotFound
}
v, err, _ := p.group.Do(remote.Name+"\x00"+key, func() (any, error) {
commit, err := p.lookup(context.WithoutCancel(ctx), remote, ref)
if err == nil || errors.Is(err, errNotFound) {
p.store(remote.Name, key, commit)
}
return commit, err
})
if err != nil {
if ok && remote.StaleOnError && !errors.Is(err, errNotFound) {
if ok && prev.commit != "" && remote.StaleOnError && !errors.Is(err, errNotFound) {
return prev.commit, nil
}
return "", err
}
return v.(string), nil
}
// store records a lookup, evicting the remote's oldest entry once it holds maxRefs.
func (p *Provider) store(remote, key, commit string) {
p.mu.Lock()
p.refs[key] = resolved{commit: commit, at: time.Now()}
p.mu.Unlock()
return commit, nil
defer p.mu.Unlock()
m := p.refs[remote]
if m == nil {
m = map[string]resolved{}
p.refs[remote] = m
}
if _, ok := m[key]; !ok && len(m) >= maxRefs {
var oldest string
for k, e := range m {
if oldest == "" || e.at.Before(m[oldest].at) {
oldest = k
}
}
delete(m, oldest)
}
m[key] = resolved{commit: commit, at: time.Now()}
}
func (p *Provider) lookup(ctx context.Context, remote models.Remote, ref target) (string, error) {
@@ -207,10 +259,10 @@ func (p *Provider) lookup(ctx context.Context, remote models.Remote, ref target)
segs[i] = url.PathEscape(s)
}
branch := strings.Join(segs, "/")
github := isGitHub(remote.BaseURL)
github := generic.IsGitHubHost(remote.BaseURL)
api := strings.TrimRight(remote.BaseURL, "/") + "/api/v1/repos/" + ref.Owner + "/" + ref.Repo + "/branches/" + branch
if github {
api = p.githubAPI + "/repos/" + ref.Owner + "/" + ref.Repo + "/commits/" + branch
api = p.githubAPI + "/repos/" + ref.Owner + "/" + ref.Repo + "/git/ref/heads/" + branch
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, api, nil)
if err != nil {
@@ -222,42 +274,36 @@ func (p *Provider) lookup(ctx context.Context, remote models.Remote, ref target)
}
req.Header = h
if github {
req.Header.Set("Accept", "application/vnd.github.sha")
req.Header.Set("Accept", "application/vnd.github+json")
}
resp, err := p.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusUnprocessableEntity {
return "", errNotFound
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("branch lookup %s: %s", api, resp.Status)
return "", provider.NewUpstreamStatusError(api, resp)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
var b struct {
Commit struct {
ID string `json:"id"`
} `json:"commit"`
Object struct {
SHA string `json:"sha"`
} `json:"object"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&b); err != nil {
return "", err
}
commit := strings.TrimSpace(string(body))
if !github {
var b struct {
Commit struct {
ID string `json:"id"`
} `json:"commit"`
}
if err := json.Unmarshal(body, &b); err != nil {
return "", err
}
commit = b.Commit.ID
commit := b.Commit.ID
if github {
commit = b.Object.SHA
}
if !commitRe.MatchString(commit) {
return "", fmt.Errorf("branch lookup %s: unexpected commit %q", api, commit)
}
return commit, nil
}
func isGitHub(baseURL string) bool {
u, err := url.Parse(baseURL)
return err == nil && strings.EqualFold(u.Hostname(), "github.com")
}
+172 -24
View File
@@ -1,12 +1,18 @@
package vcs
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"git.unkin.net/unkin/artifactapi/internal/githubauth"
"git.unkin.net/unkin/artifactapi/internal/provider"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
@@ -74,27 +80,38 @@ type upstream struct {
hits atomic.Int32
commit atomic.Value
status atomic.Int32
header atomic.Value
accept atomic.Value
auth atomic.Value
path atomic.Value
gate chan struct{}
}
func newUpstream(t *testing.T, gitea bool) *upstream {
u := &upstream{}
u.commit.Store(shaA)
u.status.Store(http.StatusOK)
u.header.Store(http.Header{})
u.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u.hits.Add(1)
if u.gate != nil {
<-u.gate
}
u.accept.Store(r.Header.Get("Accept"))
u.auth.Store(r.Header.Get("Authorization"))
u.path.Store(r.URL.EscapedPath())
for k, v := range u.header.Load().(http.Header) {
w.Header()[k] = v
}
if s := int(u.status.Load()); s != http.StatusOK {
w.WriteHeader(s)
return
}
if gitea {
w.Write([]byte(`{"name":"main","commit":{"id":"` + u.commit.Load().(string) + `"}}`))
_, _ = w.Write([]byte(`{"name":"main","commit":{"id":"` + u.commit.Load().(string) + `"}}`))
return
}
w.Write([]byte(u.commit.Load().(string)))
_, _ = w.Write([]byte(`{"ref":"refs/heads/main","object":{"sha":"` + u.commit.Load().(string) + `","type":"commit"}}`))
}))
t.Cleanup(u.srv.Close)
return u
@@ -106,10 +123,24 @@ func serve(p *Provider, remote models.Remote, path string) (*httptest.ResponseRe
return w, p.ServeRemote(w, r, remote, path, "http://proxy", nil)
}
// age backdates every cached lookup of a remote by d.
func age(p *Provider, remote string, d time.Duration) {
p.mu.Lock()
defer p.mu.Unlock()
for k, e := range p.refs[remote] {
e.at = e.at.Add(-d)
p.refs[remote][k] = e
}
}
type staticCred string
func (s staticCred) Token(context.Context) (string, error) { return string(s), nil }
func TestServeRemoteGiteaBranchRedirectsToCommit(t *testing.T) {
u := newUpstream(t, true)
p := New()
remote := models.Remote{Name: "gitea", BaseURL: u.srv.URL, MutableTTL: 3600}
remote := models.Remote{Name: "gitea", BaseURL: u.srv.URL, MutableTTL: 3600, Username: "bot", Password: "pw"}
w, handled := serve(p, remote, "unkin/app/archive/refs/heads/feature/x.zip")
if !handled || w.Code != http.StatusFound {
@@ -121,42 +152,50 @@ func TestServeRemoteGiteaBranchRedirectsToCommit(t *testing.T) {
if got := u.path.Load(); got != "/api/v1/repos/unkin/app/branches/feature/x" {
t.Errorf("api path = %q", got)
}
if got := u.auth.Load(); got != "Basic Ym90OnB3" {
t.Errorf("Authorization = %q, want remote basic auth", got)
}
}
func TestServeRemoteGitHubBranchUsesAPI(t *testing.T) {
githubauth.SetServer(staticCred("ghs_server"))
t.Cleanup(func() { githubauth.SetServer(nil) })
u := newUpstream(t, false)
p := New()
p.githubAPI = u.srv.URL
remote := models.Remote{Name: "gh", BaseURL: "https://github.com", MutableTTL: 3600}
w, _ := serve(p, remote, "cli/cli/archive/refs/heads/trunk.tar.gz")
if !strings.HasSuffix(w.Header().Get("Location"), "/cli/cli/archive/"+shaA+".tar.gz") {
t.Fatalf("Location = %q", w.Header().Get("Location"))
}
if u.path.Load() != "/repos/cli/cli/commits/trunk" || u.accept.Load() != "application/vnd.github.sha" {
t.Errorf("api path %q accept %q", u.path.Load(), u.accept.Load())
for _, base := range []string{"https://github.com", "https://www.github.com/", "https://api.github.com"} {
remote := models.Remote{Name: base, BaseURL: base, MutableTTL: 3600}
w, _ := serve(p, remote, "cli/cli/archive/refs/heads/feature/x.tar.gz")
if !strings.HasSuffix(w.Header().Get("Location"), "/cli/cli/archive/"+shaA+".tar.gz") {
t.Fatalf("%s: Location = %q", base, w.Header().Get("Location"))
}
if u.path.Load() != "/repos/cli/cli/git/ref/heads/feature/x" || u.accept.Load() != "application/vnd.github+json" {
t.Errorf("%s: api path %q accept %q", base, u.path.Load(), u.accept.Load())
}
if got := u.auth.Load(); got != "Bearer ghs_server" {
t.Errorf("%s: Authorization = %q, want server credential", base, got)
}
}
}
func TestServeRemoteBranchResolutionHonoursMutableTTL(t *testing.T) {
u := newUpstream(t, true)
p := New()
cached := models.Remote{Name: "cached", BaseURL: u.srv.URL, MutableTTL: 3600}
remote := models.Remote{Name: "g", BaseURL: u.srv.URL, MutableTTL: 600}
path := "o/r/archive/refs/heads/main.tar.gz"
serve(p, cached, path)
serve(p, remote, path)
u.commit.Store(shaB)
w, _ := serve(p, cached, path)
age(p, remote.Name, 599*time.Second)
w, _ := serve(p, remote, path)
if u.hits.Load() != 1 || !strings.Contains(w.Header().Get("Location"), shaA) {
t.Fatalf("within ttl: hits=%d location=%q", u.hits.Load(), w.Header().Get("Location"))
}
expired := cached
expired.Name = "expired"
expired.MutableTTL = 0
serve(p, expired, path)
w, _ = serve(p, expired, path)
if u.hits.Load() != 3 || !strings.Contains(w.Header().Get("Location"), shaB) {
age(p, remote.Name, 2*time.Second)
w, _ = serve(p, remote, path)
if u.hits.Load() != 2 || !strings.Contains(w.Header().Get("Location"), shaB) {
t.Fatalf("past ttl: hits=%d location=%q", u.hits.Load(), w.Header().Get("Location"))
}
}
@@ -184,14 +223,114 @@ func TestServeRemoteBranchUpstreamFailure(t *testing.T) {
}
}
func TestServeRemoteBranchNotFound(t *testing.T) {
func TestServeRemoteBranchRateLimited(t *testing.T) {
reset := strconv.FormatInt(time.Now().Add(120*time.Second).Unix(), 10)
for _, tc := range []struct {
name string
status int
header http.Header
want int
retry [2]int
}{
{"primary 403", http.StatusForbidden, http.Header{"X-Ratelimit-Remaining": {"0"}, "X-Ratelimit-Reset": {reset}}, http.StatusServiceUnavailable, [2]int{100, 122}},
{"secondary 403", http.StatusForbidden, http.Header{"Retry-After": {"30"}}, http.StatusServiceUnavailable, [2]int{30, 31}},
{"429", http.StatusTooManyRequests, http.Header{"Retry-After": {"5"}}, http.StatusServiceUnavailable, [2]int{5, 6}},
{"429 no hint", http.StatusTooManyRequests, http.Header{}, http.StatusServiceUnavailable, [2]int{60, 60}},
{"plain 403", http.StatusForbidden, http.Header{}, http.StatusBadGateway, [2]int{}},
} {
t.Run(tc.name, func(t *testing.T) {
u := newUpstream(t, false)
u.status.Store(int32(tc.status))
u.header.Store(tc.header)
p := New()
p.githubAPI = u.srv.URL
w, _ := serve(p, models.Remote{Name: "gh", BaseURL: "https://github.com"}, "o/r/archive/refs/heads/main.tar.gz")
if w.Code != tc.want {
t.Fatalf("code = %d, want %d", w.Code, tc.want)
}
ra := w.Header().Get("Retry-After")
if tc.want != http.StatusServiceUnavailable {
if ra != "" {
t.Fatalf("Retry-After = %q on %d", ra, w.Code)
}
return
}
if n, err := strconv.Atoi(ra); err != nil || n < tc.retry[0] || n > tc.retry[1] {
t.Fatalf("Retry-After = %q, want %d..%d", ra, tc.retry[0], tc.retry[1])
}
})
}
}
func TestServeRemoteBranchNotFoundIsCached(t *testing.T) {
u := newUpstream(t, false)
u.status.Store(http.StatusUnprocessableEntity)
u.status.Store(http.StatusNotFound)
p := New()
p.githubAPI = u.srv.URL
w, _ := serve(p, models.Remote{Name: "gh", BaseURL: "https://github.com"}, "o/r/archive/refs/heads/nope.tar.gz")
if w.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404", w.Code)
remote := models.Remote{Name: "gh", BaseURL: "https://github.com"}
path := "o/r/archive/refs/heads/nope.tar.gz"
for range 3 {
if w, _ := serve(p, remote, path); w.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404", w.Code)
}
}
if u.hits.Load() != 1 {
t.Fatalf("hits = %d, want 1 within negative ttl", u.hits.Load())
}
u.status.Store(http.StatusOK)
age(p, remote.Name, negativeTTL)
if w, _ := serve(p, remote, path); w.Code != http.StatusFound || u.hits.Load() != 2 {
t.Fatalf("after negative ttl: code=%d hits=%d", w.Code, u.hits.Load())
}
}
func TestServeRemoteConcurrentLookupsShareOneCall(t *testing.T) {
u := newUpstream(t, true)
u.gate = make(chan struct{})
p := New()
remote := models.Remote{Name: "g", BaseURL: u.srv.URL, MutableTTL: 600}
var wg sync.WaitGroup
codes := make([]int, 20)
for i := range codes {
wg.Add(1)
go func() {
defer wg.Done()
w, _ := serve(p, remote, "o/r/archive/refs/heads/main.tar.gz")
codes[i] = w.Code
}()
}
for u.hits.Load() == 0 {
time.Sleep(time.Millisecond)
}
time.Sleep(50 * time.Millisecond)
close(u.gate)
wg.Wait()
if u.hits.Load() != 1 {
t.Fatalf("hits = %d, want 1", u.hits.Load())
}
for i, c := range codes {
if c != http.StatusFound {
t.Fatalf("request %d: code %d", i, c)
}
}
}
func TestStoreBoundsEntriesPerRemote(t *testing.T) {
p := New()
for i := range maxRefs + 10 {
p.store("a", fmt.Sprint(i), "")
}
p.store("b", "x", shaA)
if n := len(p.refs["a"]); n != maxRefs {
t.Fatalf("remote a holds %d entries, want %d", n, maxRefs)
}
if len(p.refs["b"]) != 1 {
t.Fatalf("remote b must keep its own budget")
}
if _, ok := p.refs["a"][fmt.Sprint(maxRefs+9)]; !ok {
t.Fatalf("newest entry evicted")
}
}
@@ -215,6 +354,15 @@ func TestServeRemoteAllowlist(t *testing.T) {
}
}
func TestServeRemoteBlockedRedirectTarget(t *testing.T) {
u := newUpstream(t, true)
remote := models.Remote{Name: "g", BaseURL: u.srv.URL, Blocklist: []string{"/archive/" + shaA}}
w, _ := serve(New(), remote, "o/r/archive/refs/heads/main.tar.gz")
if w.Code != http.StatusForbidden || w.Header().Get("Location") != "" {
t.Fatalf("code = %d location %q, want 403 without redirect", w.Code, w.Header().Get("Location"))
}
}
func TestServeRemotePassesThroughAndRejects(t *testing.T) {
p := New()
remote := models.Remote{Name: "g", BaseURL: "https://github.com"}