...

Source file src/edge-infra.dev/pkg/f8n/warehouse/oci/cache/cache_test.go

Documentation: edge-infra.dev/pkg/f8n/warehouse/oci/cache

     1  package cache
     2  
     3  import (
     4  	_ "embed"
     5  	"fmt"
     6  	"io"
     7  	"log"
     8  	"net/http/httptest"
     9  	"net/url"
    10  	"os"
    11  	"testing"
    12  
    13  	"github.com/google/go-containerregistry/pkg/name"
    14  	v1 "github.com/google/go-containerregistry/pkg/v1"
    15  	"github.com/stretchr/testify/assert"
    16  	"github.com/stretchr/testify/require"
    17  
    18  	"edge-infra.dev/pkg/f8n/warehouse/cluster"
    19  	"edge-infra.dev/pkg/f8n/warehouse/lift/unpack"
    20  	"edge-infra.dev/pkg/f8n/warehouse/oci"
    21  	"edge-infra.dev/pkg/f8n/warehouse/oci/layer"
    22  	"edge-infra.dev/pkg/f8n/warehouse/oci/remote"
    23  	"edge-infra.dev/pkg/f8n/warehouse/oci/walk"
    24  	"edge-infra.dev/pkg/f8n/warehouse/pallet"
    25  	"edge-infra.dev/pkg/f8n/warehouse/whtest/registry"
    26  	"edge-infra.dev/pkg/lib/uuid"
    27  	"edge-infra.dev/test/fixtures"
    28  )
    29  
    30  var (
    31  	fpath    *fixtures.Path
    32  	r        *httptest.Server
    33  	rURL     *url.URL
    34  	shoot    oci.Artifact
    35  	redpanda oci.Artifact
    36  	certmgr  oci.Artifact
    37  	store    oci.Artifact
    38  )
    39  
    40  type simpleTestRecorder struct {
    41  	gets int
    42  	hits int
    43  }
    44  
    45  func (r *simpleTestRecorder) RecordGet(hit bool, _ string, _ string) {
    46  	r.gets++
    47  	if hit {
    48  		r.hits++
    49  	}
    50  }
    51  
    52  func (r *simpleTestRecorder) misses() int {
    53  	return r.gets - r.hits
    54  }
    55  
    56  func TestMain(m *testing.M) {
    57  	var err error
    58  
    59  	rURL, r, err = registry.New(registry.Logger(log.New(io.Discard, "", 0)))
    60  	if err != nil {
    61  		panic(err)
    62  	}
    63  	defer func() {
    64  		r.Close()
    65  	}()
    66  
    67  	fpath, err = fixtures.Layout()
    68  	if err != nil {
    69  		panic(err)
    70  	}
    71  
    72  	shoot = loadArtifact(fpath, "shoot:latest")
    73  	redpanda = loadArtifact(fpath, "redpanda-system:latest")
    74  	certmgr = loadArtifact(fpath, "cert-manager:latest")
    75  	store = loadArtifact(fpath, "store:latest")
    76  
    77  	os.Exit(m.Run())
    78  }
    79  
    80  func TestNew(t *testing.T) {
    81  	warehouseCache, err := New(
    82  		WithMemoryCacheSize(250),
    83  	)
    84  	assert.NoError(t, err)
    85  	assert.NotEmpty(t, warehouseCache)
    86  }
    87  
    88  func TestCache(t *testing.T) {
    89  	var (
    90  		id            = "test-cache"
    91  		cache         Cache
    92  		recorder      = &simpleTestRecorder{gets: 0, hits: 0}
    93  		expectedGets  = 0
    94  		shootTag      = testTag(t, id, "shoot", uuid.New().Hash())
    95  		shootDigest   = testDigest(t, id, "shoot", shoot)
    96  		certmgrTag    = testTag(t, id, "cert-manager", uuid.New().Hash())
    97  		certmgrDigest = testDigest(t, id, "cert-manager", certmgr)
    98  	)
    99  
   100  	cache, err := New(
   101  		WithMemoryCacheSize(10),
   102  		WithRecorder(recorder),
   103  	)
   104  	assert.NoError(t, err)
   105  
   106  	t.Run("Add", func(t *testing.T) {
   107  		_, err := cache.Add(shoot)
   108  		assert.NoError(t, err)
   109  	})
   110  
   111  	// - Verifies we only call RecordGet() once per Get() operation
   112  	// - Verifies expected cache hits
   113  	t.Run("Get", func(t *testing.T) {
   114  		// Ensure we fall back to remote by asking for ref we didn't add
   115  		assert.NoError(t, remote.Write(certmgr, certmgrTag))
   116  		assert.NoError(t, remote.Write(shoot, shootTag))
   117  
   118  		refs := []name.Reference{
   119  			shootTag, shootDigest, certmgrTag,
   120  		}
   121  		artifacts := []oci.Artifact{
   122  			shoot, shoot, certmgr,
   123  		}
   124  		for i, ref := range refs {
   125  			testGet(t, cache, ref, artifacts[i])
   126  			expectedGets = expectedGets + 1
   127  			assert.Equal(t, expectedGets, recorder.gets)
   128  		}
   129  		assert.Equal(t, 2, recorder.hits)
   130  		assert.Equal(t, 1, recorder.misses())
   131  
   132  		refs = []name.Reference{
   133  			certmgrTag, certmgrDigest,
   134  		}
   135  		artifacts = []oci.Artifact{
   136  			certmgr, certmgr,
   137  		}
   138  		for i, ref := range refs {
   139  			testGet(t, cache, ref, artifacts[i])
   140  			expectedGets = expectedGets + 1
   141  			assert.Equal(t, expectedGets, recorder.gets)
   142  		}
   143  		assert.Equal(t, 4, recorder.hits)
   144  		assert.Equal(t, 1, recorder.misses())
   145  
   146  		// Ensure that a unfamiliar tag with a resolve digest doesn't
   147  		// break
   148  		newTag := testTag(t, id, "cert-manager", uuid.New().Hash())
   149  		assert.NoError(t, remote.Write(certmgr, newTag))
   150  		t.Log(certmgrDigest)
   151  		testGet(t, cache, newTag, certmgr)
   152  		expectedGets = expectedGets + 1
   153  		assert.Equal(t, expectedGets, recorder.gets)
   154  
   155  		assert.Equal(t, 5, recorder.hits)
   156  		assert.Equal(t, 1, recorder.misses())
   157  	})
   158  
   159  	t.Run("Get_ResolveTag", func(t *testing.T) {
   160  		_, err := cache.Add(redpanda)
   161  		assert.NoError(t, err)
   162  		assert.NoError(t, remote.Write(redpanda, certmgrTag))
   163  		testGet(t, cache, certmgrTag, redpanda, ResolveTag())
   164  		expectedGets = expectedGets + 1
   165  		assert.Equal(t, expectedGets, recorder.gets)
   166  
   167  		assert.Equal(t, 6, recorder.hits)
   168  		assert.Equal(t, 1, recorder.misses())
   169  	})
   170  }
   171  
   172  func TestLazyFetch(t *testing.T) {
   173  	var (
   174  		id          = "test-lazy-cache"
   175  		cache       Cache
   176  		recorder    = &simpleTestRecorder{0, 0}
   177  		storeTag    = testTag(t, id, "store", uuid.New().Hash())
   178  		storeDigest = testDigest(t, id, "store", store)
   179  	)
   180  
   181  	// compute what blobs exist in store artifact sourced from v1.Layout, so we
   182  	// dont cause the cached artifact to be computed
   183  	blobs, blobCount, err := analyzeArtifact(store)
   184  	require.NoError(t, err)
   185  	// same for creating pallet
   186  	p, err := pallet.New(store)
   187  	require.NoError(t, err)
   188  	// write to registry, cache will lazily pull remote impl of store from here
   189  	require.NoError(t, remote.Write(store, storeDigest))
   190  
   191  	cache, err = New(
   192  		WithMemoryCacheSize(500),
   193  		WithRecorder(recorder),
   194  	)
   195  	require.NoError(t, err)
   196  
   197  	cachedStore := testColdGet(t, cache, storeDigest, blobs)
   198  
   199  	// We don't actually need to interact with result, just force all layers to be
   200  	// visited.
   201  	computeArtifact(t, cachedStore, p.Providers()...)
   202  
   203  	// We should have visited all layers and all of our blobs should now be in the
   204  	// cache.
   205  	checkCacheForBlobs(t, cache, blobs, blobCount)
   206  	assert.Equal(t, cache.Len(), blobCount, "cache has more blobs than expected")
   207  
   208  	// Shouldn't trigger any misses going forward
   209  	finalMissCount := recorder.misses()
   210  	_, err = cache.Get(storeDigest)
   211  	assert.NoError(t, err)
   212  	assert.Equal(t, finalMissCount, recorder.misses())
   213  
   214  	t.Run("Tags", func(t *testing.T) {
   215  		// Ensure we resolve tag for digest we have already cached without performing
   216  		// fetch.
   217  		require.NoError(t, remote.Tag(storeTag.(name.Tag), store))
   218  		cachedStore, err := cache.Get(storeTag)
   219  		assert.NoError(t, err)
   220  		sameDigest(t, store, cachedStore)
   221  		assert.Equal(t, finalMissCount, recorder.misses())
   222  	})
   223  }
   224  
   225  func TestCommonBlobs(t *testing.T) {
   226  	// TODO: this would be best suited for a test recorder that keeps track of
   227  	// what object types its seen. LazyCache implements [Recorder] including tracking
   228  	// object type
   229  	var (
   230  		id          = "test-lazy-common-blobs"
   231  		cache       Cache
   232  		recorder    = &simpleTestRecorder{0, 0}
   233  		shootRef    = testDigest(t, id, "store", shoot)
   234  		redpandaRef = testDigest(t, id, "redpanda-system", redpanda)
   235  	)
   236  
   237  	cache, err := New(
   238  		WithMemoryCacheSize(500),
   239  		WithRecorder(recorder),
   240  	)
   241  	require.NoError(t, err)
   242  
   243  	shootBlobs, shootBlobCount, err := analyzeArtifact(shoot)
   244  	require.NoError(t, err)
   245  	shootPallet, err := pallet.New(shoot)
   246  	require.NoError(t, err)
   247  	require.NoError(t, remote.Write(shoot, shootRef))
   248  
   249  	redpandaBlobs, redpandaBlobCount, err := analyzeArtifact(redpanda)
   250  	require.NoError(t, err)
   251  	redpandaPallet, err := pallet.New(redpanda)
   252  	require.NoError(t, err)
   253  	require.NoError(t, remote.Write(redpanda, redpandaRef))
   254  
   255  	// Get dependency first and force it to cache
   256  	cachedRedpanda := testColdGet(t, cache, redpandaRef, redpandaBlobs)
   257  	computeArtifact(t, cachedRedpanda, redpandaPallet.Providers()...)
   258  	// Confirm it was cached
   259  	checkCacheForBlobs(t, cache, redpandaBlobs, redpandaBlobCount)
   260  	assert.Equal(t, cache.Len(), redpandaBlobCount, "cache has more blobs than expected")
   261  
   262  	// Only verify the unique blobs for shoot are missing
   263  	missingShootBlobs := map[string][]v1.Hash{} // should be missing
   264  	for objType, hashes := range shootBlobs {
   265  		for _, hash := range hashes {
   266  			found := false
   267  			for _, rpHash := range redpandaBlobs[objType] {
   268  				if hash == rpHash {
   269  					found = true
   270  					break
   271  				}
   272  			}
   273  			if !found {
   274  				missingShootBlobs[objType] = append(missingShootBlobs[objType], hash)
   275  			}
   276  		}
   277  	}
   278  	cachedShoot := testColdGet(t, cache, shootRef, missingShootBlobs)
   279  	// Capture misses at this point so that we can analyze the difference after
   280  	// computing the artifact
   281  	misses := recorder.misses()
   282  	computeArtifact(t, cachedShoot, shootPallet.Providers()...)
   283  	// Confirm it was cached
   284  	checkCacheForBlobs(t, cache, shootBlobs, shootBlobCount)
   285  	assert.Equal(t, cache.Len(), shootBlobCount, "cache has more blobs than expected")
   286  	// We should have hit the cache for all common blobs. Because some blobs are used
   287  	// for multiple providers, hits will grow faster than expected, but misses
   288  	// should be equal to:
   289  	// (shoots unique blobs - redpandas blobs) - 1 (because we fetched manifest earlier when we cached)
   290  	// + the misses before we pulled (so we only count misses for pulling shoot through)
   291  	assert.Equal(t, (shootBlobCount-redpandaBlobCount-1)+misses, recorder.misses())
   292  }
   293  
   294  func sameDigest(t *testing.T, exp, actual oci.Artifact) {
   295  	ad, err := actual.Digest()
   296  	assert.NoError(t, err)
   297  
   298  	ed, err := exp.Digest()
   299  	assert.NoError(t, err)
   300  
   301  	assert.Equal(t, ed, ad)
   302  }
   303  
   304  func testTag(t *testing.T, r, n, tag string) name.Reference {
   305  	t.Helper()
   306  	ref, err := name.NewTag(fmt.Sprintf("%s/%s/%s:%s", rURL.Host, r, n, tag))
   307  	require.NoError(t, err)
   308  	return ref
   309  }
   310  
   311  func testDigest(t *testing.T, r, n string, a oci.Artifact) name.Reference {
   312  	t.Helper()
   313  	d, err := a.Digest()
   314  	require.NoError(t, err)
   315  	ref, err := name.NewDigest(fmt.Sprintf("%s/%s/%s@%s", rURL.Host, r, n, d))
   316  	require.NoError(t, err)
   317  	return ref
   318  }
   319  
   320  func testColdGet(t *testing.T, c Cache, ref name.Reference, blobs map[string][]v1.Hash) oci.Artifact {
   321  	t.Helper()
   322  
   323  	cached, err := c.Get(ref)
   324  	require.NoError(t, err)
   325  
   326  	d, err := cached.Digest()
   327  	require.NoError(t, err)
   328  
   329  	assert.True(t, c.Exists(d), "%s expected to be in cache", d)
   330  	// Verify we are being as lazy as expected
   331  	for objType, hashes := range blobs {
   332  		for _, hash := range hashes {
   333  			if hash != d {
   334  				assert.False(t, c.Exists(hash),
   335  					"%s %s unexpectedly in cache", objType, hash)
   336  			}
   337  		}
   338  	}
   339  
   340  	return cached
   341  }
   342  
   343  func checkCacheForBlobs(t *testing.T, c Cache, blobs map[string][]v1.Hash, expCount int) {
   344  	t.Helper()
   345  
   346  	var missing []v1.Hash
   347  	for _, hashes := range blobs {
   348  		for _, hash := range hashes {
   349  			if !c.Exists(hash) {
   350  				missing = append(missing, hash)
   351  			}
   352  		}
   353  	}
   354  	if len(missing) > 0 {
   355  		t.Errorf("%d/%d, expected digests not in the cache: %v", len(missing), expCount, missing)
   356  	}
   357  }
   358  
   359  func testGet(t *testing.T, c Cache, ref name.Reference, exp oci.Artifact, opts ...GetOption) {
   360  	t.Helper()
   361  	t.Log("ref", ref)
   362  	a, err := c.Get(ref, opts...)
   363  	assert.NoError(t, err)
   364  	h, err := a.Digest()
   365  	assert.NoError(t, err)
   366  	assert.True(t, c.Exists(h))
   367  	assert.NoError(t, err)
   368  	sameDigest(t, exp, a)
   369  }
   370  
   371  // helper for loading artifacts in TestMain. panics if fetching the artifact
   372  // from the layout fails, not appropriate for other Test functions
   373  func loadArtifact(p *fixtures.Path, refstr string) oci.Artifact {
   374  	ref, err := name.ParseReference(refstr)
   375  	if err != nil {
   376  		panic(err)
   377  	}
   378  
   379  	a, err := p.Get(ref)
   380  	if err != nil {
   381  		panic(err)
   382  	}
   383  
   384  	return a
   385  }
   386  
   387  // return number of blobs and map of type of blob to hashes present for that
   388  // type. everything in the returned map should be present in the cache after
   389  // computing the lazyImage/lazyIndex
   390  //
   391  // NOTE: this function will cause the cache to be at least partially computed,
   392  // if you want to avoid that, use the uncached implementation (eg v1/layout or
   393  // v1/remote)
   394  func analyzeArtifact(a oci.Artifact) (map[string][]v1.Hash, int, error) {
   395  	result := make(map[string][]v1.Hash)
   396  	count := 0
   397  
   398  	if err := walk.Walk(a, &walk.Fns{
   399  		Index: func(ii v1.ImageIndex, _ v1.ImageIndex) error {
   400  			h, err := ii.Digest()
   401  			if err != nil {
   402  				return err
   403  			}
   404  			for _, idxHash := range result["index"] {
   405  				if idxHash == h {
   406  					return nil
   407  				}
   408  			}
   409  			result["index"] = append(result["index"], h)
   410  			count++
   411  			return nil
   412  		},
   413  		Image: func(img v1.Image, _ v1.ImageIndex) error {
   414  			h, err := img.Digest()
   415  			if err != nil {
   416  				return err
   417  			}
   418  			for _, imgHash := range result["image"] {
   419  				if imgHash == h {
   420  					return nil
   421  				}
   422  			}
   423  			result["image"] = append(result["image"], h)
   424  			count++
   425  			return nil
   426  		},
   427  		Layer: func(l layer.Layer, _ v1.Image) error {
   428  			h, err := l.Digest()
   429  			if err != nil {
   430  				return err
   431  			}
   432  			for _, layerHash := range result["layer"] {
   433  				if layerHash == h {
   434  					return nil
   435  				}
   436  			}
   437  			result["layer"] = append(result["layer"], h)
   438  			count++
   439  			return nil
   440  		},
   441  	}); err != nil {
   442  		return nil, 0, err
   443  	}
   444  
   445  	return result, count, nil
   446  }
   447  
   448  // computeArtifact performs a no-op unpack of the input layers to force lazy
   449  // implementations to resolve and cache themselves.
   450  func computeArtifact(t *testing.T, a oci.Artifact, providers ...cluster.Provider) {
   451  	t.Helper()
   452  
   453  	fn := func(pallet.Pallet, []layer.Layer) error { return nil }
   454  	for _, provider := range providers {
   455  		t.Log("unpacked provider", provider)
   456  		assert.NoError(t, unpack.Walk(a, fn, unpack.ForProvider(provider)))
   457  	}
   458  }
   459  

View as plain text