From f88678360f48d7ac54971e47dfbf03e82ac00613 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 24 Oct 2026 17:30:40 +1100 Subject: [PATCH] Drop local files and metadata with their remote --- e2e-docker/local_test.go | 54 +++++++++++++++++------------- internal/database/database_test.go | 28 ++++++++++++++++ internal/database/remotes.go | 15 +++++++-- 3 files changed, 71 insertions(+), 26 deletions(-) diff --git a/e2e-docker/local_test.go b/e2e-docker/local_test.go index 562fa68..6d90eed 100644 --- a/e2e-docker/local_test.go +++ b/e2e-docker/local_test.go @@ -150,31 +150,15 @@ func TestLocalAlpineIndex(t *testing.T) { apk := testsupport.MinimalApk("e2e-testpkg", "1.0-r0", "x86_64") uploadFile(t, "local-alpine", "x86_64/e2e-testpkg-1.0-r0.apk", apk, "application/vnd.android.package-archive") - // The index is generated asynchronously after upload; poll for it. - resp, body := getEventually(t, api("/api/v1/local/local-alpine/x86_64/APKINDEX.tar.gz"), 15*time.Second) - if resp.StatusCode != http.StatusOK { - t.Fatalf("APKINDEX: status %d: %s", resp.StatusCode, body) - } - - zr, err := gzip.NewReader(bytes.NewReader(body)) - if err != nil { - t.Fatalf("APKINDEX not gzip: %v", err) - } - tarBytes, _ := io.ReadAll(zr) - tr := tar.NewReader(bytes.NewReader(tarBytes)) + // The index is served (empty) at once but lists the package only once the + // upload's metadata is parsed asynchronously; poll until it does. var index string - for { - hdr, err := tr.Next() - if err == io.EOF { - break - } - if err != nil { - t.Fatalf("APKINDEX not tar: %v", err) - } - if hdr.Name == "APKINDEX" { - b, _ := io.ReadAll(tr) - index = string(b) + for deadline := time.Now().Add(15 * time.Second); !strings.Contains(index, "P:e2e-testpkg") && time.Now().Before(deadline); time.Sleep(250 * time.Millisecond) { + resp, body := doRequest(t, http.MethodGet, api("/api/v1/local/local-alpine/x86_64/APKINDEX.tar.gz"), nil, "") + if resp.StatusCode != http.StatusOK { + t.Fatalf("APKINDEX: status %d: %s", resp.StatusCode, body) } + index = apkIndexText(t, body) } for _, want := range []string{"P:e2e-testpkg", "V:1.0-r0", "A:x86_64", "C:Q1", "S:", "I:"} { if !strings.Contains(index, want) { @@ -183,7 +167,7 @@ func TestLocalAlpineIndex(t *testing.T) { } // The .apk downloads back byte-identical from its arch path. - resp, body = doRequest(t, http.MethodGet, api("/api/v1/local/local-alpine/x86_64/e2e-testpkg-1.0-r0.apk"), nil, "") + resp, body := doRequest(t, http.MethodGet, api("/api/v1/local/local-alpine/x86_64/e2e-testpkg-1.0-r0.apk"), nil, "") if resp.StatusCode != http.StatusOK { t.Fatalf("download apk: status %d: %s", resp.StatusCode, body) } @@ -191,3 +175,25 @@ func TestLocalAlpineIndex(t *testing.T) { t.Fatalf("apk content mismatch") } } + +func apkIndexText(t *testing.T, body []byte) string { + t.Helper() + zr, err := gzip.NewReader(bytes.NewReader(body)) + if err != nil { + t.Fatalf("APKINDEX not gzip: %v", err) + } + tr := tar.NewReader(zr) + for { + hdr, err := tr.Next() + if err == io.EOF { + return "" + } + if err != nil { + t.Fatalf("APKINDEX not tar: %v", err) + } + if hdr.Name == "APKINDEX" { + b, _ := io.ReadAll(tr) + return string(b) + } + } +} diff --git a/internal/database/database_test.go b/internal/database/database_test.go index 2f11049..ef0e8a9 100644 --- a/internal/database/database_test.go +++ b/internal/database/database_test.go @@ -307,6 +307,34 @@ func TestLocalFiles(t *testing.T) { } } +// A repo recreated under a deleted repo's name must not inherit its files or +// metadata, or re-uploading the same path 409s. +func TestDeleteRemoteDropsLocalFiles(t *testing.T) { + requireDB(t) + seedRemote(t, "r-recreate") + hash := seedBlob(t, "recreatehash") + if err := testDB.CreateLocalFile(ctx(), "r-recreate", "x86_64/a-1.0-r0.apk", hash); err != nil { + t.Fatal(err) + } + if err := testDB.InsertAlpineMetadata(ctx(), &provider.AlpineMetadata{RepoName: "r-recreate", FilePath: "x86_64/a-1.0-r0.apk", ContentHash: hash, Name: "a", Version: "1.0-r0", Arch: "x86_64"}); err != nil { + t.Fatal(err) + } + if err := testDB.DeleteRemote(ctx(), "r-recreate"); err != nil { + t.Fatal(err) + } + seedRemote(t, "r-recreate") + defer testDB.DeleteRemote(ctx(), "r-recreate") + if f, err := testDB.GetLocalFile(ctx(), "r-recreate", "x86_64/a-1.0-r0.apk"); err != nil || f != nil { + t.Fatalf("recreated repo inherited local file: %v %v", f, err) + } + if rows, err := testDB.ListAlpineMetadataEntries(ctx(), "r-recreate"); err != nil || len(rows) != 0 { + t.Fatalf("recreated repo inherited alpine metadata: %v %v", rows, err) + } + if err := testDB.CreateLocalFile(ctx(), "r-recreate", "x86_64/a-1.0-r0.apk", hash); err != nil { + t.Fatalf("re-upload after recreate: %v", err) + } +} + func TestVirtualsCRUD(t *testing.T) { requireDB(t) if err := testDB.CreateVirtual(ctx(), &models.Virtual{ diff --git a/internal/database/remotes.go b/internal/database/remotes.go index 76afa06..44b2eb5 100644 --- a/internal/database/remotes.go +++ b/internal/database/remotes.go @@ -3,6 +3,8 @@ package database import ( "context" + "github.com/jackc/pgx/v5" + "git.unkin.net/unkin/artifactapi/pkg/models" ) @@ -116,7 +118,16 @@ func (db *DB) UpdateRemote(ctx context.Context, r *models.Remote) error { return err } +// DeleteRemote drops a remote with its local files and package metadata, so a +// repo recreated under the same name starts empty. func (db *DB) DeleteRemote(ctx context.Context, name string) error { - _, err := db.Pool.Exec(ctx, `DELETE FROM remotes WHERE name = $1`, name) - return err + return pgx.BeginFunc(ctx, db.Pool, func(tx pgx.Tx) error { + for _, table := range []string{"local_files", "rpm_metadata", "deb_metadata", "alpine_metadata", "puppet_metadata", "helm_metadata"} { + if _, err := tx.Exec(ctx, `DELETE FROM `+table+` WHERE repo_name = $1`, name); err != nil { + return err + } + } + _, err := tx.Exec(ctx, `DELETE FROM remotes WHERE name = $1`, name) + return err + }) } -- 2.47.3