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 @@ -1381,6 +1381,7 @@ Registered when `cache_delete` is set. It has no authentication of its own.
| Endpoint | Description |
|----------|-------------|
| `DELETE /api/cache/{ecosystem}/{name}/{version}` | Delete a version's cached files, or only `?filename=` |
| `DELETE /api/git/{upstream}/{repo}` | Delete a repository from the git cache |

### Enrichment API

Expand Down
7 changes: 4 additions & 3 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,10 @@ base_url: "http://localhost:8080"
# scanning. Set to "0" to disable. Default: "5m". Does not limit proxy mirror.
# artifact_timeout: "5m"

# Register DELETE /api/cache and show Delete buttons on the UI's version page,
# for removing cached artifacts so they are fetched again. Has no
# authentication: restrict /api/cache and /ui at the ingress first.
# Register DELETE /api/cache and, with upstream.git set, DELETE /api/git, and
# show Delete buttons on the UI's version and git pages, for removing cached
# artifacts and repositories so they are fetched again. Has no authentication:
# restrict /api/cache, /api/git and /ui at the ingress first.
# cache_delete: false

# Largest upstream artifact to fetch (e.g. "2GB"). Larger ones are refused
Expand Down
10 changes: 9 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1888,7 +1888,7 @@ cache_delete: true

Or via environment variable: `PROXY_CACHE_DELETE=true`.

The endpoint has no authentication. Before enabling it, restrict `/api/cache` and `/ui` to trusted users at your ingress or reverse proxy. The UI sends its deletes to `/ui/api/cache`, so whatever protects `/ui` protects those too.
The endpoints have no authentication. Before enabling them, restrict `/api/cache`, `/api/git` and `/ui` to trusted users at your ingress or reverse proxy. The UI sends its deletes to `/ui/api/cache` and `/ui/api/git`, so whatever protects `/ui` protects those too.

Delete every cached file of a version, or only the one named by `filename`:

Expand All @@ -1901,6 +1901,14 @@ The name is the package name the UI shows, for example `group:artifact` for Mave

Pushed archives are not touched: a whole-version delete skips them, and naming one answers 409. Delete those through the [hosted API](#hosted-packages).

With `upstream.git` set, `DELETE /api/git/{upstream}/{repo}` removes one repository from the [git cache](#git-repositories), and the `/ui/git` page gets a Delete button per repository:

```bash
curl -X DELETE http://localhost:8080/api/git/github/acme/widgets.git
```

`{upstream}` is the name under `upstream.git`, and `{repo}` the repository path with or without `.git`. The response names the repository and the bytes freed (`freed_bytes`). The next clone fetches it from the upstream again. A repository that a clone, a fetch or a client is using at that moment answers 409; try again once it is done. Git LFS objects are deleted through `/api/cache` like other artifacts.

## Mirror Command

The `proxy mirror` command pre-populates the cache from various sources. It accepts the same storage and database flags as `serve`.
Expand Down
59 changes: 59 additions & 0 deletions docs/swagger/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,54 @@ const docTemplate = `{
}
}
},
"/api/git/{upstream}/{repo}": {
"delete": {
"description": "Removes one repository from the git cache, so the next clone fetches it from the upstream again. A repository that a clone, a fetch or a client is using answers 409. Needs cache_delete.",
"produces": [
"application/json"
],
"tags": [
"cache"
],
"summary": "Delete a cached Git repository",
"parameters": [
{
"type": "string",
"description": "Name of the upstream.git entry",
"name": "upstream",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Repository path under the upstream, with or without .git",
"name": "repo",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/server.GitDeleteResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
},
"409": {
"description": "Conflict",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
}
}
}
},
"/api/hosted/{ecosystem}/{name}": {
"get": {
"produces": [
Expand Down Expand Up @@ -1074,6 +1122,17 @@ const docTemplate = `{
}
}
},
"server.GitDeleteResponse": {
"type": "object",
"properties": {
"freed_bytes": {
"type": "integer"
},
"repository": {
"type": "string"
}
}
},
"server.HealthCheck": {
"type": "object",
"properties": {
Expand Down
59 changes: 59 additions & 0 deletions docs/swagger/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,54 @@
}
}
},
"/api/git/{upstream}/{repo}": {
"delete": {
"description": "Removes one repository from the git cache, so the next clone fetches it from the upstream again. A repository that a clone, a fetch or a client is using answers 409. Needs cache_delete.",
"produces": [
"application/json"
],
"tags": [
"cache"
],
"summary": "Delete a cached Git repository",
"parameters": [
{
"type": "string",
"description": "Name of the upstream.git entry",
"name": "upstream",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Repository path under the upstream, with or without .git",
"name": "repo",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/server.GitDeleteResponse"
}
},
"404": {
"description": "Not Found",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
},
"409": {
"description": "Conflict",
"schema": {
"$ref": "#/definitions/server.ErrorResponse"
}
}
}
}
},
"/api/hosted/{ecosystem}/{name}": {
"get": {
"produces": [
Expand Down Expand Up @@ -1067,6 +1115,17 @@
}
}
},
"server.GitDeleteResponse": {
"type": "object",
"properties": {
"freed_bytes": {
"type": "integer"
},
"repository": {
"type": "string"
}
}
},
"server.HealthCheck": {
"type": "object",
"properties": {
Expand Down
7 changes: 4 additions & 3 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,9 +212,10 @@ type Config struct {
// Mirror configures scheduled mirroring.
Mirror MirrorConfig `json:"mirror" yaml:"mirror"`

// CacheDelete enables DELETE /api/cache for removing cached artifacts, and
// the delete buttons on the UI's version page. It has no authentication of
// its own, so restrict /api/cache and /ui at the ingress when enabling it.
// CacheDelete enables DELETE /api/cache and DELETE /api/git for removing
// cached artifacts and git repositories, and the delete buttons on the
// UI's version and git pages. It has no authentication of its own, so
// restrict /api/cache, /api/git and /ui at the ingress when enabling it.
CacheDelete bool `json:"cache_delete" yaml:"cache_delete"`

// Hosted configures pushing packages to the proxy (/api/hosted).
Expand Down
67 changes: 67 additions & 0 deletions internal/gitcache/remove.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package gitcache

import (
"errors"
"os"
"path/filepath"
"strings"
)

var (
// ErrNotCached is returned by Remove for a repository the cache does
// not hold.
ErrNotCached = errors.New("repository is not cached")
// ErrInUse is returned by Remove for a repository a clone, a fetch or
// a client is using.
ErrInUse = errors.New("repository is in use")
)

// Remove deletes the cached repository under key, as eviction does, and
// returns its size. The next request for it clones it again. A repository
// in use is not removed and returns ErrInUse, so nothing reads it while it
// is deleted.
func (c *Cache) Remove(key string) (int64, error) {
c.mu.Lock()
rp := c.repos[c.repoKey(key)]
if rp == nil {
c.mu.Unlock()
return 0, ErrNotCached
}
// Before the directory check: a first clone is built elsewhere and
// moved into place once done.
if rp.users > 0 {
c.mu.Unlock()
return 0, ErrInUse
}
if !rp.exists() {
c.mu.Unlock()
return 0, ErrNotCached
}
// Moved aside under the lock, so a request that comes after it clones
// the repository again instead of reading it while it is deleted.
trash, err := os.MkdirTemp(c.tmpDir(), "removed-")
if err == nil {
err = os.Rename(rp.dir, filepath.Join(trash, "repo"))
}
if err != nil {
c.mu.Unlock()
if trash != "" {
_ = os.Remove(trash)
}
return 0, err
}
delete(c.repos, rp.key)
delete(c.tooLarge, rp.key)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
// A want the old copy could not fit may fit in the next clone.
for k := range c.failedWants {
if strings.HasPrefix(k, rp.key+" ") {
delete(c.failedWants, k)
}
}
size := rp.size.Load()
c.mu.Unlock()

c.logger.Info("git cache: removed repository", "repository", rp.key, "size", size)
c.removeEvicted([]string{trash})
return size, nil
}
117 changes: 117 additions & 0 deletions internal/gitcache/remove_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package gitcache

import (
"errors"
"path/filepath"
"testing"
)

func TestRemoveDeletesARepository(t *testing.T) {
upstream := newFakeUpstream(t)
upstream.newRepo("repo")
tc := newTestCache(t, upstream.server.URL, nil)
tc.clone(t, "repo")
waitIdle(t, tc.Cache)
dir := filepath.Join(tc.dir, "up", "repo.git")
want := tc.repo("up/repo.git").size.Load()

freed, err := tc.Remove("up/repo.git")
if err != nil {
t.Fatalf("Remove: %v", err)
}
if freed != want || freed == 0 {
t.Errorf("freed %d bytes, want %d", freed, want)
}
if fileExists(dir) || tc.repo("up/repo.git") != nil {
t.Error("the repository is still cached")
}
if len(tc.Repositories()) != 0 {
t.Errorf("Repositories() = %+v, want none", tc.Repositories())
}
if _, err := tc.Remove("up/repo.git"); !errors.Is(err, ErrNotCached) {
t.Errorf("second Remove: %v, want ErrNotCached", err)
}

// The next clone fetches it again.
requests := upstream.gitRequests.Load()
tc.clone(t, "repo")
if upstream.gitRequests.Load() == requests || !fileExists(dir) {
t.Error("the clone after Remove did not fetch the repository again")
}
}

// A repository that a clone, a fetch or a client is using is not removed,
// so nothing reads it while it is deleted.
func TestRemoveRefusesARepositoryInUse(t *testing.T) {
upstream := newFakeUpstream(t)
upstream.newRepo("repo")
tc := newTestCache(t, upstream.server.URL, nil)
tc.clone(t, "repo")
waitIdle(t, tc.Cache)

rp := tc.acquire("up/repo.git")
if _, err := tc.Remove("up/repo.git"); !errors.Is(err, ErrInUse) {
t.Errorf("Remove while in use: %v, want ErrInUse", err)
}
tc.release(rp)
if !fileExists(filepath.Join(tc.dir, "up", "repo.git")) {
t.Fatal("the repository was removed while in use")
}
if _, err := tc.Remove("up/repo.git"); err != nil {
t.Errorf("Remove once idle: %v", err)
}
}

func TestRemoveUnknownRepository(t *testing.T) {
upstream := newFakeUpstream(t)
tc := newTestCache(t, upstream.server.URL, nil)
for _, key := range []string{"up/missing.git", "up/../up.git", ""} {
if _, err := tc.Remove(key); !errors.Is(err, ErrNotCached) {
t.Errorf("Remove(%q): %v, want ErrNotCached", key, err)
}
}
}

// A repository whose first clone is still running is in use, not missing.
func TestRemoveRefusesARepositoryBeingCloned(t *testing.T) {
upstream := newFakeUpstream(t)
upstream.newRepo("repo")
tc := newTestCache(t, upstream.server.URL, nil)
arrived, release := upstream.holdFetches()
cloned := make(chan error, 1)
go func() {
_, err := tryGit(t, "", "clone", "--quiet", tc.url("repo"), filepath.Join(t.TempDir(), "clone"))
cloned <- err
}()
<-arrived
if _, err := tc.Remove("up/repo.git"); !errors.Is(err, ErrInUse) {
t.Errorf("Remove during the first clone: %v, want ErrInUse", err)
}
release()
if err := <-cloned; err != nil {
t.Fatalf("clone: %v", err)
}
}

// Wants a removed repository could not get may fit once it is cloned again,
// so removing it clears them, and only them.
func TestRemoveForgetsTheRepositorysFailedWants(t *testing.T) {
upstream := newFakeUpstream(t)
upstream.newRepo("repo")
tc := newTestCache(t, upstream.server.URL, nil)
tc.clone(t, "repo")
waitIdle(t, tc.Cache)
oid := "0123456789abcdef0123456789abcdef01234567"
tc.recordFailedWants("up/repo.git", []string{oid}, ErrTooLarge)
tc.recordFailedWants("up/other.git", []string{oid}, ErrTooLarge)

if _, err := tc.Remove("up/repo.git"); err != nil {
t.Fatalf("Remove: %v", err)
}
if got := tc.withoutFailedWants("up/repo.git", []string{oid}); len(got) != 1 {
t.Error("the removed repository's failed want is still held back")
}
if got := tc.withoutFailedWants("up/other.git", []string{oid}); len(got) != 0 {
t.Error("another repository's failed want was cleared")
}
}
Loading
Loading