Compare commits

...

2 Commits

Author SHA1 Message Date
unkinben 3a3b7fe7b7 feat: redirect / to the web UI (#101)
ci/woodpecker/tag/docker Pipeline was successful
## Why

The web UI ships as a separate image served under \`/ui\` (built with \`BASE_PATH=/ui\`). Hitting the bare domain (e.g. \`https://artifactapi.k8s.syd1.au.unkin.net/\`) returned the API's JSON identity blob instead of the app, so browsers never landed on the UI.

## Changes

- Redirect \`GET /\` to \`/ui/\` (302 Found).
- Preserve the former root JSON (\`{"name","version"}\`) at \`/version\`, so health/monitoring can still read the running version.
- Update the server integration test to assert the redirect and the \`/version\` payload.

Reviewed-on: #101
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-07-03 15:00:19 +10:00
unkinben 0ec28660ba fix: prune RPM metadata when a local file is evicted (#100)
ci/woodpecker/tag/docker Pipeline was successful
Follow-up to #99.

## Why

Evicting or deleting a local RPM removed the \`local_files\` row but left its \`rpm_metadata\` behind. Since generated repodata is built from \`rpm_metadata\`, \`primary.xml\` kept advertising a package that no longer exists, producing 404s for clients that tried to fetch it.

## Changes

- Add \`PostDeleteHook\` and \`MetadataDeleter\` provider interfaces (symmetric to the existing \`PostUploadHook\`/\`MetadataStore\`), plus a \`DeleteRPMMetadata\` DB method.
- Implement \`AfterDelete\` in the RPM provider to drop the metadata row for the deleted file.
- Route both local delete paths — the new \`evictLocal\` and the existing files handler's \`remove\` — through a shared \`deleteLocalFile\` helper that removes the file then runs the provider's post-delete hook. Non-RPM providers have no hook, so nothing changes for them.
- Cover the cleanup with a dockerised test.

Reviewed-on: #100
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-07-03 14:54:28 +10:00
8 changed files with 151 additions and 4 deletions
+23 -1
View File
@@ -185,13 +185,35 @@ func (h *LocalHandler) remove(w http.ResponseWriter, r *http.Request) {
repoName := chi.URLParam(r, "name")
filePath := chi.URLParam(r, "*")
if err := h.db.DeleteLocalFile(r.Context(), repoName, filePath); err != nil {
if err := deleteLocalFile(r.Context(), h.db, repoName, filePath); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// deleteLocalFile removes a local file and runs the provider's post-delete hook,
// so provider-derived state (e.g. RPM metadata that feeds generated repodata)
// stops referencing a package that no longer exists.
func deleteLocalFile(ctx context.Context, db *database.DB, repoName, filePath string) error {
if err := db.DeleteLocalFile(ctx, repoName, filePath); err != nil {
return err
}
remote, err := db.GetRemote(ctx, repoName)
if err != nil {
return nil // file is gone; no repo left to resolve a cleanup hook from
}
prov, err := provider.Get(remote.PackageType)
if err != nil {
return nil
}
if hook, ok := prov.(provider.PostDeleteHook); ok {
return hook.AfterDelete(ctx, repoName, filePath, db)
}
return nil
}
func (h *LocalHandler) DB() *database.DB {
return h.db
}
@@ -0,0 +1,75 @@
package v2
import (
"context"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"git.unkin.net/unkin/artifactapi/internal/database"
"git.unkin.net/unkin/artifactapi/internal/provider"
_ "git.unkin.net/unkin/artifactapi/internal/provider/rpm" // register the rpm provider so its PostDeleteHook runs
"git.unkin.net/unkin/artifactapi/pkg/models"
)
// TestLocalEvictCleansRPMMetadata verifies that evicting an RPM from a local
// repo also removes the derived rpm_metadata row, so generated repodata stops
// listing the deleted package.
func TestLocalEvictCleansRPMMetadata(t *testing.T) {
if testDSN == "" {
t.Skip("Docker unavailable")
}
ctx := context.Background()
db, err := database.New(testDSN)
if err != nil {
t.Fatal(err)
}
defer db.Close()
const repo = "rpm-evict-cleanup"
if err := db.CreateRemote(ctx, &models.Remote{Name: repo, PackageType: models.PackageRPM, RepoType: models.RepoTypeLocal}); err != nil {
t.Fatal(err)
}
const hash = "sha256:bb22"
const path = "Packages/example-0.1.0-1.x86_64.rpm"
if err := db.UpsertBlob(ctx, hash, "blobs/bb/22", 2048, "application/x-rpm"); err != nil {
t.Fatal(err)
}
if err := db.CreateLocalFile(ctx, repo, path, hash); err != nil {
t.Fatal(err)
}
if err := db.InsertRPMMetadata(ctx, &provider.RPMMetadata{
RepoName: repo, FilePath: path, ContentHash: hash,
Name: "example", Version: "0.1.0", Release: "1", Arch: "x86_64",
Requires: []provider.RPMDep{}, Provides: []provider.RPMDep{},
Files: []provider.RPMFile{}, Changelogs: []provider.RPMChangelog{},
}); err != nil {
t.Fatal(err)
}
h := NewObjectsHandler(db)
router := chi.NewRouter()
router.Route("/locals/{name}/objects", func(r chi.Router) {
r.Delete("/*", h.LocalRoutes().ServeHTTP)
})
del := httptest.NewRequest("DELETE", "/locals/"+repo+"/objects/"+path, nil)
dw := httptest.NewRecorder()
router.ServeHTTP(dw, del)
if dw.Code != 204 {
t.Fatalf("evict = %d, want 204", dw.Code)
}
if f, _ := db.GetLocalFile(ctx, repo, path); f != nil {
t.Fatalf("local file still present after evict: %+v", f)
}
entries, err := db.ListRPMMetadataEntries(ctx, repo)
if err != nil {
t.Fatal(err)
}
if len(entries) != 0 {
t.Fatalf("rpm_metadata still present after evict: %+v", entries)
}
}
+1 -1
View File
@@ -75,7 +75,7 @@ func (h *ObjectsHandler) evictLocal(w http.ResponseWriter, r *http.Request) {
repoName := chi.URLParam(r, "name")
path := chi.URLParam(r, "*")
if err := h.db.DeleteLocalFile(r.Context(), repoName, path); err != nil {
if err := deleteLocalFile(r.Context(), h.db, repoName, path); err != nil {
http.Error(w, fmt.Sprintf("evict failed: %v", err), http.StatusInternalServerError)
return
}
+5
View File
@@ -32,6 +32,11 @@ func (db *DB) InsertRPMMetadata(ctx context.Context, meta *provider.RPMMetadata)
return err
}
func (db *DB) DeleteRPMMetadata(ctx context.Context, repoName, filePath string) error {
_, err := db.Pool.Exec(ctx, `DELETE FROM rpm_metadata WHERE repo_name = $1 AND file_path = $2`, repoName, filePath)
return err
}
type RPMMetadataRow struct {
RepoName string
FilePath string
+10
View File
@@ -53,10 +53,20 @@ type PostUploadHook interface {
AfterUpload(ctx context.Context, repoName, storagePath, contentHash string, blobs BlobReader, db MetadataStore)
}
// PostDeleteHook lets a provider clean up derived state (e.g. RPM metadata that
// feeds generated repodata) after a local file is removed.
type PostDeleteHook interface {
AfterDelete(ctx context.Context, repoName, storagePath string, db MetadataDeleter) error
}
type MetadataStore interface {
InsertRPMMetadata(ctx context.Context, meta *RPMMetadata) error
}
type MetadataDeleter interface {
DeleteRPMMetadata(ctx context.Context, repoName, filePath string) error
}
type RPMMetadataReader interface {
ListRPMMetadataEntries(ctx context.Context, repoName string) ([]RPMMetadata, error)
}
+9
View File
@@ -151,6 +151,15 @@ func (p *Provider) AfterUpload(ctx context.Context, repoName, storagePath, conte
slog.Info("rpm metadata: parsed", "repo", repoName, "name", meta.Name, "version", meta.Version, "arch", meta.Arch)
}
func (p *Provider) AfterDelete(ctx context.Context, repoName, storagePath string, db provider.MetadataDeleter) error {
if err := db.DeleteRPMMetadata(ctx, repoName, storagePath); err != nil {
slog.Error("rpm metadata: delete failed", "repo", repoName, "path", storagePath, "error", err)
return err
}
slog.Info("rpm metadata: deleted", "repo", repoName, "path", storagePath)
return nil
}
func rpmDepFromEntry(e rpmlib.Dependency) provider.RPMDep {
dep := provider.RPMDep{Name: e.Name()}
if e.Flags() != 0 {
+7
View File
@@ -95,6 +95,7 @@ func (s *Server) routes() chi.Router {
r.Get("/health", s.handleHealth)
r.Get("/", s.handleRoot)
r.Get("/version", s.handleVersion)
proxyHandler := v1.NewProxyHandler(s.engine, s.virtEngine, s.db, s.store, s.localHandler)
r.Mount("/api/v1", proxyHandler.Routes())
@@ -143,7 +144,13 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"status":"ok"}`)
}
// handleRoot sends browsers landing on the bare domain to the web UI, which is
// served under /ui. The service identity that used to live here is at /version.
func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/ui/", http.StatusFound)
}
func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, `{"name":"artifactapi","version":"%s"}`, s.version)
+21 -2
View File
@@ -129,13 +129,32 @@ func req(t *testing.T, method, path string, body string) (*http.Response, []byte
return resp, b
}
// reqNoRedirect issues a request without following redirects so the response's
// status and Location header can be asserted directly.
func reqNoRedirect(t *testing.T, method, path string) *http.Response {
t.Helper()
rq, _ := http.NewRequest(method, testTS.URL+path, nil)
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}}
resp, err := client.Do(rq)
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
resp.Body.Close()
return resp
}
func TestServerHealthAndRoot(t *testing.T) {
requireStack(t)
if resp, _ := req(t, "GET", "/health", ""); resp.StatusCode != 200 {
t.Errorf("health: %d", resp.StatusCode)
}
if resp, b := req(t, "GET", "/", ""); resp.StatusCode != 200 || !strings.Contains(string(b), "test-version") {
t.Errorf("root: %d %s", resp.StatusCode, b)
if resp := reqNoRedirect(t, "GET", "/"); resp.StatusCode != http.StatusFound || resp.Header.Get("Location") != "/ui/" {
t.Errorf("root redirect: %d %q", resp.StatusCode, resp.Header.Get("Location"))
}
if resp, b := req(t, "GET", "/version", ""); resp.StatusCode != 200 || !strings.Contains(string(b), "test-version") {
t.Errorf("version: %d %s", resp.StatusCode, b)
}
if resp, _ := req(t, "GET", "/api/v2/health", ""); resp.StatusCode != 200 {
t.Errorf("health v2: %d", resp.StatusCode)