336 lines
10 KiB
Go
336 lines
10 KiB
Go
package mirror
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/grmrgecko/repo-sync/fetch"
|
|
"github.com/grmrgecko/repo-sync/internal/testrepos"
|
|
)
|
|
|
|
// countingArchive serves a fixture tree, counting the directory listings a
|
|
// crawl requests so a test can tell a fresh scan from a reused one.
|
|
func countingArchive(t *testing.T, www string) (*httptest.Server, *atomic.Int64) {
|
|
t.Helper()
|
|
var listings atomic.Int64
|
|
files := http.FileServer(http.Dir(www))
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.HasSuffix(r.URL.Path, "/") {
|
|
listings.Add(1)
|
|
}
|
|
files.ServeHTTP(w, r)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
return srv, &listings
|
|
}
|
|
|
|
// readDiscoverState reads the discovery cache a run left at a destination.
|
|
func readDiscoverState(t *testing.T, dest string) discoverState {
|
|
t.Helper()
|
|
data, err := os.ReadFile(filepath.Join(dest, fetch.DiscoverStateName))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var state discoverState
|
|
if err := json.Unmarshal(data, &state); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return state
|
|
}
|
|
|
|
// TestDiscoverCacheReuse verifies a crawl's results are reused until they
|
|
// age out, so a repeat run costs no directory listings, and that scanning
|
|
// again picks up what the upstream has published since.
|
|
func TestDiscoverCacheReuse(t *testing.T) {
|
|
www := t.TempDir()
|
|
testrepos.BuildRPMRepo(t, filepath.Join(www, "el9"))
|
|
srv, listings := countingArchive(t, www)
|
|
|
|
dest := t.TempDir()
|
|
opts := &Options{
|
|
URLs: []string{srv.URL},
|
|
Destination: dest,
|
|
Discover: true,
|
|
DiscoverDepth: 3,
|
|
DiscoverCache: time.Hour,
|
|
Workers: 2,
|
|
}
|
|
|
|
// The first run scans the tree and records what it found.
|
|
summary, err := Sync(context.Background(), opts)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if summary.Repositories != 1 {
|
|
t.Fatalf("repositories = %d, want 1", summary.Repositories)
|
|
}
|
|
scanned := listings.Load()
|
|
if scanned == 0 {
|
|
t.Fatal("the first run listed no directories")
|
|
}
|
|
state := readDiscoverState(t, dest)
|
|
rec := state.Crawls[crawlKey(srv.URL)]
|
|
if rec == nil || len(rec.Repos) != 1 || rec.Repos[0].Type != string(RepoRPM) {
|
|
t.Fatalf("recorded crawl = %+v", rec)
|
|
}
|
|
|
|
// A repository published after that scan is not picked up while the
|
|
// cached results are still current, and the tree is not listed again.
|
|
testrepos.BuildRPMRepo(t, filepath.Join(www, "el10"))
|
|
summary, err = Sync(context.Background(), opts)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if summary.Repositories != 1 {
|
|
t.Errorf("repositories = %d on the cached run, want 1", summary.Repositories)
|
|
}
|
|
if got := listings.Load(); got != scanned {
|
|
t.Errorf("the cached run listed %d directories", got-scanned)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dest, "el10")); err == nil {
|
|
t.Error("a repository published after the scan was mirrored from the cache")
|
|
}
|
|
|
|
// Scanning on every run finds it.
|
|
opts.DiscoverCache = 0
|
|
summary, err = Sync(context.Background(), opts)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if summary.Repositories != 2 {
|
|
t.Errorf("repositories = %d after re-scanning, want 2", summary.Repositories)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dest, "el10", "repodata", "repomd.xml")); err != nil {
|
|
t.Error("the newly published repository was not mirrored:", err)
|
|
}
|
|
}
|
|
|
|
// TestDiscoverCacheOptionsChange verifies a run that changes what a crawl
|
|
// would find scans again rather than replaying the previous results.
|
|
func TestDiscoverCacheOptionsChange(t *testing.T) {
|
|
www := t.TempDir()
|
|
testrepos.BuildRPMRepo(t, filepath.Join(www, "el9"))
|
|
testrepos.BuildRPMRepo(t, filepath.Join(www, "sles15"))
|
|
srv, _ := countingArchive(t, www)
|
|
|
|
dest := t.TempDir()
|
|
opts := &Options{
|
|
URLs: []string{srv.URL},
|
|
Destination: dest,
|
|
Discover: true,
|
|
DiscoverDepth: 3,
|
|
DiscoverCache: time.Hour,
|
|
Workers: 2,
|
|
}
|
|
summary, err := Sync(context.Background(), opts)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if summary.Repositories != 2 {
|
|
t.Fatalf("repositories = %d, want 2", summary.Repositories)
|
|
}
|
|
|
|
// The cache is still current, but the run no longer asks for the same
|
|
// tree, so the crawl has to happen again.
|
|
opts.Exclude = []string{"sles*"}
|
|
summary, err = Sync(context.Background(), opts)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if summary.Repositories != 1 {
|
|
t.Errorf("repositories = %d after excluding one, want 1", summary.Repositories)
|
|
}
|
|
}
|
|
|
|
// TestDiscoverCacheEviction verifies a repository the upstream withdrew is
|
|
// removed from the mirror when pruning is enabled, and kept otherwise.
|
|
func TestDiscoverCacheEviction(t *testing.T) {
|
|
www := t.TempDir()
|
|
testrepos.BuildRPMRepo(t, filepath.Join(www, "el9"))
|
|
testrepos.BuildRPMRepo(t, filepath.Join(www, "el10"))
|
|
srv, _ := countingArchive(t, www)
|
|
|
|
dest := t.TempDir()
|
|
opts := &Options{
|
|
URLs: []string{srv.URL},
|
|
Destination: dest,
|
|
Discover: true,
|
|
DiscoverDepth: 3,
|
|
Workers: 2,
|
|
}
|
|
if _, err := Sync(context.Background(), opts); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dest, "el10", "repodata", "repomd.xml")); err != nil {
|
|
t.Fatal("second repository not mirrored:", err)
|
|
}
|
|
|
|
// Withdraw it upstream. Without pruning the local copy stays, but the
|
|
// crawl records that it is gone.
|
|
if err := os.RemoveAll(filepath.Join(www, "el10")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := Sync(context.Background(), opts); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dest, "el10")); err != nil {
|
|
t.Error("a withdrawn repository was removed without pruning:", err)
|
|
}
|
|
|
|
// With pruning it is evicted, and the record goes with it.
|
|
opts.Prune = true
|
|
if _, err := Sync(context.Background(), opts); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dest, "el10")); !os.IsNotExist(err) {
|
|
t.Error("a withdrawn repository survived a pruning run")
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dest, "el9", "repodata", "repomd.xml")); err != nil {
|
|
t.Error("the surviving repository was evicted too:", err)
|
|
}
|
|
for _, repo := range readDiscoverState(t, dest).Crawls[crawlKey(srv.URL)].Repos {
|
|
if strings.HasSuffix(repo.URL, "/el10") {
|
|
t.Error("the evicted repository is still recorded")
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDiscoverCacheEvictionGrace verifies a withdrawn repository waits out
|
|
// the prune grace before it is evicted, so one bad crawl does not take a
|
|
// tree with it.
|
|
func TestDiscoverCacheEvictionGrace(t *testing.T) {
|
|
www := t.TempDir()
|
|
testrepos.BuildRPMRepo(t, filepath.Join(www, "el9"))
|
|
testrepos.BuildRPMRepo(t, filepath.Join(www, "el10"))
|
|
srv, _ := countingArchive(t, www)
|
|
|
|
dest := t.TempDir()
|
|
opts := &Options{
|
|
URLs: []string{srv.URL},
|
|
Destination: dest,
|
|
Discover: true,
|
|
DiscoverDepth: 3,
|
|
Workers: 2,
|
|
Prune: true,
|
|
PruneGrace: time.Hour,
|
|
}
|
|
if _, err := Sync(context.Background(), opts); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.RemoveAll(filepath.Join(www, "el10")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := Sync(context.Background(), opts); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dest, "el10")); err != nil {
|
|
t.Error("a withdrawn repository was evicted inside its grace period:", err)
|
|
}
|
|
|
|
// Age the recorded withdrawal past the grace period; the next run
|
|
// evicts it.
|
|
state := readDiscoverState(t, dest)
|
|
aged := time.Now().Add(-2 * time.Hour)
|
|
for _, repo := range state.Crawls[crawlKey(srv.URL)].Repos {
|
|
if strings.HasSuffix(repo.URL, "/el10") {
|
|
if repo.Gone == nil {
|
|
t.Fatal("the withdrawal was not recorded")
|
|
}
|
|
repo.Gone = &aged
|
|
}
|
|
}
|
|
data, err := json.Marshal(state)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dest, fetch.DiscoverStateName), data, 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := Sync(context.Background(), opts); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dest, "el10")); !os.IsNotExist(err) {
|
|
t.Error("a withdrawn repository survived its grace period")
|
|
}
|
|
}
|
|
|
|
// TestDiscoverCacheEvictionNeedsCleanCrawl verifies a crawl that could not
|
|
// list part of the tree withdraws nothing: an upstream that failed to
|
|
// answer is not an upstream that dropped a repository.
|
|
func TestDiscoverCacheEvictionNeedsCleanCrawl(t *testing.T) {
|
|
www := t.TempDir()
|
|
testrepos.BuildRPMRepo(t, filepath.Join(www, "el9"))
|
|
testrepos.BuildRPMRepo(t, filepath.Join(www, "el10"))
|
|
|
|
// The second run answers the el10 listing with an error, which is how a
|
|
// repository that is still published can go missing from a crawl.
|
|
var broken atomic.Bool
|
|
files := http.FileServer(http.Dir(www))
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if broken.Load() && strings.HasPrefix(r.URL.Path, "/el10") {
|
|
http.Error(w, "upstream is having a bad day", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
files.ServeHTTP(w, r)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
dest := t.TempDir()
|
|
opts := &Options{
|
|
URLs: []string{srv.URL},
|
|
Destination: dest,
|
|
Discover: true,
|
|
DiscoverDepth: 3,
|
|
Workers: 2,
|
|
Prune: true,
|
|
}
|
|
if _, err := Sync(context.Background(), opts); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
broken.Store(true)
|
|
if _, err := Sync(context.Background(), opts); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dest, "el10", "repodata", "repomd.xml")); err != nil {
|
|
t.Error("a failed listing evicted a repository that is still published:", err)
|
|
}
|
|
for _, repo := range readDiscoverState(t, dest).Crawls[crawlKey(srv.URL)].Repos {
|
|
if strings.HasSuffix(repo.URL, "/el10") && repo.Gone != nil {
|
|
t.Error("a failed listing recorded a repository as withdrawn")
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDiscoverCacheDryRun verifies a planning run neither records a crawl
|
|
// nor spends the cache's lifetime.
|
|
func TestDiscoverCacheDryRun(t *testing.T) {
|
|
www := t.TempDir()
|
|
testrepos.BuildRPMRepo(t, filepath.Join(www, "el9"))
|
|
srv, _ := countingArchive(t, www)
|
|
|
|
dest := t.TempDir()
|
|
opts := &Options{
|
|
URLs: []string{srv.URL},
|
|
Destination: dest,
|
|
Discover: true,
|
|
DiscoverDepth: 3,
|
|
DiscoverCache: time.Hour,
|
|
Workers: 2,
|
|
DryRun: true,
|
|
}
|
|
if _, err := Sync(context.Background(), opts); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dest, fetch.DiscoverStateName)); !os.IsNotExist(err) {
|
|
t.Error("a dry run recorded a crawl")
|
|
}
|
|
}
|