Reject bare * and unknown remotes on evict, serialise with fetch lock
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful

- return 400 for a bare * and 404 for an unknown remote
- take the per-path fetch lock for single-path evicts
- pass the evictor to Routes so local routes no longer carry it
- test If-None-Match revalidation, ForgetPath/ForgetPrefix glob escaping
This commit is contained in:
2026-10-04 15:56:30 +11:00
parent 5802fd2e2c
commit c1c5ff987c
8 changed files with 218 additions and 32 deletions
+1 -1
View File
@@ -49,7 +49,7 @@ func TestLocalEvictCleansRPMMetadata(t *testing.T) {
t.Fatal(err)
}
h := NewObjectsHandler(db, nil)
h := NewObjectsHandler(db)
router := chi.NewRouter()
router.Route("/locals/{name}/objects", func(r chi.Router) {
r.Delete("/*", h.LocalRoutes().ServeHTTP)
+1 -1
View File
@@ -40,7 +40,7 @@ func TestLocalObjectsListing(t *testing.T) {
t.Fatal(err)
}
h := NewObjectsHandler(db, nil)
h := NewObjectsHandler(db)
router := chi.NewRouter()
router.Route("/locals/{name}/objects", func(r chi.Router) {
r.Get("/", h.LocalRoutes().ServeHTTP)
+17 -8
View File
@@ -2,6 +2,7 @@ package v2
import (
"context"
"errors"
"fmt"
"net/http"
"strconv"
@@ -9,6 +10,7 @@ import (
"github.com/go-chi/chi/v5"
"git.unkin.net/unkin/artifactapi/internal/database"
"git.unkin.net/unkin/artifactapi/internal/proxy"
)
// Evictor drops a remote path from every cache layer.
@@ -17,18 +19,20 @@ type Evictor interface {
}
type ObjectsHandler struct {
db *database.DB
evictor Evictor
db *database.DB
}
func NewObjectsHandler(db *database.DB, evictor Evictor) *ObjectsHandler {
return &ObjectsHandler{db: db, evictor: evictor}
func NewObjectsHandler(db *database.DB) *ObjectsHandler {
return &ObjectsHandler{db: db}
}
func (h *ObjectsHandler) Routes() chi.Router {
// Routes lists and evicts objects for remote repos; evictor serves the DELETE.
func (h *ObjectsHandler) Routes(evictor Evictor) chi.Router {
r := chi.NewRouter()
r.Get("/", h.list)
r.Delete("/*", h.evict)
r.Delete("/*", func(w http.ResponseWriter, r *http.Request) {
evict(w, r, evictor)
})
return r
}
@@ -89,11 +93,16 @@ func (h *ObjectsHandler) evictLocal(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
func (h *ObjectsHandler) evict(w http.ResponseWriter, r *http.Request) {
func evict(w http.ResponseWriter, r *http.Request, evictor Evictor) {
remoteName := chi.URLParam(r, "name")
path := chi.URLParam(r, "*")
if err := h.evictor.Evict(r.Context(), remoteName, path); err != nil {
if err := evictor.Evict(r.Context(), remoteName, path); err != nil {
var proxyErr *proxy.ProxyError
if errors.As(err, &proxyErr) {
http.Error(w, proxyErr.Message, proxyErr.Status)
return
}
http.Error(w, fmt.Sprintf("evict failed: %v", err), http.StatusInternalServerError)
return
}
+27 -11
View File
@@ -2,31 +2,47 @@ package v2
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"git.unkin.net/unkin/artifactapi/internal/proxy"
)
type fakeEvictor struct{ remote, path string }
type fakeEvictor struct {
remote, path string
err error
}
func (f *fakeEvictor) Evict(_ context.Context, remote, path string) error {
f.remote, f.path = remote, path
return nil
return f.err
}
func deleteObject(ev Evictor, path string) int {
router := chi.NewRouter()
router.Route("/remotes/{name}/objects", func(r chi.Router) {
r.Delete("/*", NewObjectsHandler(nil).Routes(ev).ServeHTTP)
})
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest("DELETE", "/remotes/epel/objects/"+path, nil))
return w.Code
}
func TestRemoteEvictDelegatesToEvictor(t *testing.T) {
for _, path := range []string{"8/Everything/x86_64/repodata/repomd.xml", "8/Everything/x86_64/repodata/*"} {
ev := &fakeEvictor{}
h := NewObjectsHandler(nil, ev)
router := chi.NewRouter()
router.Route("/remotes/{name}/objects", func(r chi.Router) {
r.Delete("/*", h.Routes().ServeHTTP)
})
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest("DELETE", "/remotes/epel/objects/"+path, nil))
if w.Code != 204 || ev.remote != "epel" || ev.path != path {
t.Errorf("DELETE %s: code=%d evicted=%q/%q", path, w.Code, ev.remote, ev.path)
if code := deleteObject(ev, path); code != 204 || ev.remote != "epel" || ev.path != path {
t.Errorf("DELETE %s: code=%d evicted=%q/%q", path, code, ev.remote, ev.path)
}
}
}
func TestRemoteEvictMapsProxyErrorStatus(t *testing.T) {
ev := &fakeEvictor{err: &proxy.ProxyError{Status: http.StatusNotFound, Message: "remote not found"}}
if code := deleteObject(ev, "x"); code != http.StatusNotFound {
t.Errorf("code = %d, want 404", code)
}
}