github.com/argoproj/argo-cd/v3@v3.2.1/util/oci/client_test.go (about)

     1  package oci
     2  
     3  import (
     4  	"archive/tar"
     5  	"bytes"
     6  	"compress/gzip"
     7  	"context"
     8  	"encoding/json"
     9  	"errors"
    10  	"io"
    11  	"os"
    12  	"path/filepath"
    13  	"testing"
    14  
    15  	"github.com/opencontainers/go-digest"
    16  	"github.com/opencontainers/image-spec/specs-go"
    17  	imagev1 "github.com/opencontainers/image-spec/specs-go/v1"
    18  	"github.com/stretchr/testify/require"
    19  	"oras.land/oras-go/v2"
    20  	"oras.land/oras-go/v2/content"
    21  	"oras.land/oras-go/v2/content/memory"
    22  
    23  	utilio "github.com/argoproj/argo-cd/v3/util/io"
    24  	"github.com/argoproj/argo-cd/v3/util/io/files"
    25  )
    26  
    27  type layerConf struct {
    28  	desc  imagev1.Descriptor
    29  	bytes []byte
    30  }
    31  
    32  func generateManifest(t *testing.T, store *memory.Store, layerDescs ...layerConf) string {
    33  	t.Helper()
    34  	configBlob := []byte("Hello config")
    35  	configDesc := content.NewDescriptorFromBytes(imagev1.MediaTypeImageConfig, configBlob)
    36  
    37  	var layers []imagev1.Descriptor
    38  
    39  	for _, layer := range layerDescs {
    40  		layers = append(layers, layer.desc)
    41  	}
    42  
    43  	manifestBlob, err := json.Marshal(imagev1.Manifest{
    44  		Config:    configDesc,
    45  		Layers:    layers,
    46  		Versioned: specs.Versioned{SchemaVersion: 2},
    47  	})
    48  	require.NoError(t, err)
    49  	manifestDesc := content.NewDescriptorFromBytes(imagev1.MediaTypeImageManifest, manifestBlob)
    50  
    51  	for _, layer := range layerDescs {
    52  		require.NoError(t, store.Push(t.Context(), layer.desc, bytes.NewReader(layer.bytes)))
    53  	}
    54  
    55  	require.NoError(t, store.Push(t.Context(), configDesc, bytes.NewReader(configBlob)))
    56  	require.NoError(t, store.Push(t.Context(), manifestDesc, bytes.NewReader(manifestBlob)))
    57  	require.NoError(t, store.Tag(t.Context(), manifestDesc, manifestDesc.Digest.String()))
    58  
    59  	return manifestDesc.Digest.String()
    60  }
    61  
    62  func createGzippedTarWithContent(t *testing.T, filename, content string) []byte {
    63  	t.Helper()
    64  	var buf bytes.Buffer
    65  	gzw := gzip.NewWriter(&buf)
    66  	tw := tar.NewWriter(gzw)
    67  
    68  	require.NoError(t, tw.WriteHeader(&tar.Header{
    69  		Name: filename,
    70  		Mode: 0o644,
    71  		Size: int64(len(content)),
    72  	}))
    73  	_, err := tw.Write([]byte(content))
    74  	require.NoError(t, err)
    75  	require.NoError(t, tw.Close())
    76  	require.NoError(t, gzw.Close())
    77  
    78  	return buf.Bytes()
    79  }
    80  
    81  func addFileToDirectory(t *testing.T, dir, filename, content string) {
    82  	t.Helper()
    83  
    84  	filePath := filepath.Join(dir, filename)
    85  	err := os.WriteFile(filePath, []byte(content), 0o644)
    86  	require.NoError(t, err)
    87  }
    88  
    89  func Test_nativeOCIClient_Extract(t *testing.T) {
    90  	cacheDir := utilio.NewRandomizedTempPaths(t.TempDir())
    91  
    92  	type fields struct {
    93  		repoURL           string
    94  		tagsFunc          func(context.Context, string) (tags []string, err error)
    95  		allowedMediaTypes []string
    96  	}
    97  	type args struct {
    98  		manifestMaxExtractedSize        int64
    99  		disableManifestMaxExtractedSize bool
   100  		digestFunc                      func(*memory.Store) string
   101  		postValidationFunc              func(string, string, Client, fields, args)
   102  	}
   103  	tests := []struct {
   104  		name          string
   105  		fields        fields
   106  		args          args
   107  		expectedError error
   108  	}{
   109  		{
   110  			name: "extraction fails due to size limit",
   111  			fields: fields{
   112  				allowedMediaTypes: []string{imagev1.MediaTypeImageLayerGzip},
   113  			},
   114  			args: args{
   115  				digestFunc: func(store *memory.Store) string {
   116  					layerBlob := createGzippedTarWithContent(t, "some-path", "some content")
   117  					return generateManifest(t, store, layerConf{content.NewDescriptorFromBytes(imagev1.MediaTypeImageLayerGzip, layerBlob), layerBlob})
   118  				},
   119  				manifestMaxExtractedSize:        10,
   120  				disableManifestMaxExtractedSize: false,
   121  			},
   122  			expectedError: errors.New("cannot extract contents of oci image with revision sha256:1b6dfd71e2b35c2f35dffc39007c2276f3c0e235cbae4c39cba74bd406174e22: failed to perform \"Push\" on destination: could not decompress layer: error while iterating on tar reader: unexpected EOF"),
   123  		},
   124  		{
   125  			name: "extraction fails due to multiple content layers",
   126  			fields: fields{
   127  				allowedMediaTypes: []string{imagev1.MediaTypeImageLayerGzip},
   128  			},
   129  			args: args{
   130  				digestFunc: func(store *memory.Store) string {
   131  					layerBlob := createGzippedTarWithContent(t, "some-path", "some content")
   132  					otherLayerBlob := createGzippedTarWithContent(t, "some-other-path", "some other content")
   133  					return generateManifest(t, store, layerConf{content.NewDescriptorFromBytes(imagev1.MediaTypeImageLayerGzip, layerBlob), layerBlob}, layerConf{content.NewDescriptorFromBytes(imagev1.MediaTypeImageLayerGzip, otherLayerBlob), otherLayerBlob})
   134  				},
   135  				manifestMaxExtractedSize:        1000,
   136  				disableManifestMaxExtractedSize: false,
   137  			},
   138  			expectedError: errors.New("expected only a single oci content layer, got 2"),
   139  		},
   140  		{
   141  			name: "extraction with multiple layers, but just a single content layer",
   142  			fields: fields{
   143  				allowedMediaTypes: []string{imagev1.MediaTypeImageLayerGzip},
   144  			},
   145  			args: args{
   146  				digestFunc: func(store *memory.Store) string {
   147  					layerBlob := createGzippedTarWithContent(t, "some-path", "some content")
   148  					return generateManifest(t, store, layerConf{content.NewDescriptorFromBytes(imagev1.MediaTypeImageLayerGzip, layerBlob), layerBlob}, layerConf{content.NewDescriptorFromBytes("application/vnd.cncf.helm.chart.provenance.v1.prov", []byte{}), []byte{}})
   149  				},
   150  				manifestMaxExtractedSize:        1000,
   151  				disableManifestMaxExtractedSize: false,
   152  			},
   153  		},
   154  		{
   155  			name: "extraction fails due to invalid media type",
   156  			fields: fields{
   157  				allowedMediaTypes: []string{"application/vnd.different.media.type"},
   158  			},
   159  			args: args{
   160  				digestFunc: func(store *memory.Store) string {
   161  					layerBlob := "Hello layer"
   162  					return generateManifest(t, store, layerConf{content.NewDescriptorFromBytes(imagev1.MediaTypeImageLayerGzip, []byte(layerBlob)), []byte(layerBlob)})
   163  				},
   164  				manifestMaxExtractedSize:        1000,
   165  				disableManifestMaxExtractedSize: false,
   166  			},
   167  			expectedError: errors.New("oci layer media type application/vnd.oci.image.layer.v1.tar+gzip is not in the list of allowed media types"),
   168  		},
   169  		{
   170  			name: "extraction fails due to non-existent digest",
   171  			fields: fields{
   172  				allowedMediaTypes: []string{"application/vnd.cncf.helm.chart.content.v1.tar+gzip"},
   173  			},
   174  			args: args{
   175  				digestFunc: func(_ *memory.Store) string {
   176  					return "sha256:nonexistentdigest"
   177  				},
   178  				manifestMaxExtractedSize:        1000,
   179  				disableManifestMaxExtractedSize: false,
   180  			},
   181  			expectedError: errors.New("error resolving oci repo from digest, sha256:nonexistentdigest: not found"),
   182  		},
   183  		{
   184  			name: "extraction with helm chart",
   185  			fields: fields{
   186  				allowedMediaTypes: []string{"application/vnd.cncf.helm.chart.content.v1.tar+gzip"},
   187  			},
   188  			args: args{
   189  				digestFunc: func(store *memory.Store) string {
   190  					chartDir := t.TempDir()
   191  					chartName := "mychart"
   192  
   193  					parent := filepath.Join(chartDir, "parent")
   194  					require.NoError(t, os.Mkdir(parent, 0o755))
   195  
   196  					chartPath := filepath.Join(parent, chartName)
   197  					require.NoError(t, os.Mkdir(chartPath, 0o755))
   198  
   199  					addFileToDirectory(t, chartPath, "Chart.yaml", "some content")
   200  
   201  					temp, err := os.CreateTemp(t.TempDir(), "")
   202  					require.NoError(t, err)
   203  					defer temp.Close()
   204  					_, err = files.Tgz(parent, nil, nil, temp)
   205  					require.NoError(t, err)
   206  					_, err = temp.Seek(0, io.SeekStart)
   207  					require.NoError(t, err)
   208  					all, err := io.ReadAll(temp)
   209  
   210  					require.NoError(t, err)
   211  
   212  					return generateManifest(t, store, layerConf{content.NewDescriptorFromBytes("application/vnd.cncf.helm.chart.content.v1.tar+gzip", all), all})
   213  				},
   214  				postValidationFunc: func(_, path string, _ Client, _ fields, _ args) {
   215  					tempDir, err := files.CreateTempDir(os.TempDir())
   216  					defer os.RemoveAll(tempDir)
   217  					require.NoError(t, err)
   218  					chartDir, err := os.ReadDir(path)
   219  					require.NoError(t, err)
   220  					require.Len(t, chartDir, 1)
   221  					require.Equal(t, "Chart.yaml", chartDir[0].Name())
   222  					chartYaml, err := os.Open(filepath.Join(path, chartDir[0].Name()))
   223  					require.NoError(t, err)
   224  					contents, err := io.ReadAll(chartYaml)
   225  					require.NoError(t, err)
   226  					require.Equal(t, "some content", string(contents))
   227  				},
   228  				manifestMaxExtractedSize:        10000,
   229  				disableManifestMaxExtractedSize: false,
   230  			},
   231  		},
   232  		{
   233  			name: "extraction with standard gzip layer",
   234  			fields: fields{
   235  				allowedMediaTypes: []string{imagev1.MediaTypeImageLayerGzip},
   236  			},
   237  			args: args{
   238  				digestFunc: func(store *memory.Store) string {
   239  					layerBlob := createGzippedTarWithContent(t, "foo.yaml", "some content")
   240  					return generateManifest(t, store, layerConf{content.NewDescriptorFromBytes(imagev1.MediaTypeImageLayerGzip, layerBlob), layerBlob})
   241  				},
   242  				postValidationFunc: func(_, path string, _ Client, _ fields, _ args) {
   243  					manifestDir, err := os.ReadDir(path)
   244  					require.NoError(t, err)
   245  					require.Len(t, manifestDir, 1)
   246  					require.Equal(t, "foo.yaml", manifestDir[0].Name())
   247  					f, err := os.Open(filepath.Join(path, manifestDir[0].Name()))
   248  					require.NoError(t, err)
   249  					contents, err := io.ReadAll(f)
   250  					require.NoError(t, err)
   251  					require.Equal(t, "some content", string(contents))
   252  				},
   253  				manifestMaxExtractedSize:        1000,
   254  				disableManifestMaxExtractedSize: false,
   255  			},
   256  		},
   257  		{
   258  			name: "extraction with standard gzip layer using cache",
   259  			fields: fields{
   260  				allowedMediaTypes: []string{imagev1.MediaTypeImageLayerGzip},
   261  			},
   262  			args: args{
   263  				digestFunc: func(store *memory.Store) string {
   264  					layerBlob := createGzippedTarWithContent(t, "foo.yaml", "some content")
   265  					return generateManifest(t, store, layerConf{content.NewDescriptorFromBytes(imagev1.MediaTypeImageLayerGzip, layerBlob), layerBlob})
   266  				},
   267  				manifestMaxExtractedSize:        1000,
   268  				disableManifestMaxExtractedSize: false,
   269  				postValidationFunc: func(sha string, _ string, _ Client, fields fields, args args) {
   270  					store := memory.New()
   271  					c := newClientWithLock(fields.repoURL, globalLock, store, fields.tagsFunc, func(_ context.Context) error {
   272  						return nil
   273  					}, fields.allowedMediaTypes, WithImagePaths(cacheDir), WithManifestMaxExtractedSize(args.manifestMaxExtractedSize), WithDisableManifestMaxExtractedSize(args.disableManifestMaxExtractedSize))
   274  					_, gotCloser, err := c.Extract(t.Context(), sha)
   275  					require.NoError(t, err)
   276  					require.NoError(t, gotCloser.Close())
   277  				},
   278  			},
   279  		},
   280  	}
   281  
   282  	for _, tt := range tests {
   283  		t.Run(tt.name, func(t *testing.T) {
   284  			store := memory.New()
   285  			sha := tt.args.digestFunc(store)
   286  
   287  			c := newClientWithLock(tt.fields.repoURL, globalLock, store, tt.fields.tagsFunc, func(_ context.Context) error {
   288  				return nil
   289  			}, tt.fields.allowedMediaTypes, WithImagePaths(cacheDir), WithManifestMaxExtractedSize(tt.args.manifestMaxExtractedSize), WithDisableManifestMaxExtractedSize(tt.args.disableManifestMaxExtractedSize))
   290  			path, gotCloser, err := c.Extract(t.Context(), sha)
   291  
   292  			if tt.expectedError != nil {
   293  				require.EqualError(t, err, tt.expectedError.Error())
   294  				return
   295  			}
   296  
   297  			require.NoError(t, err)
   298  			require.NotEmpty(t, path)
   299  			require.NotNil(t, gotCloser)
   300  
   301  			exists, err := fileExists(path)
   302  			require.True(t, exists)
   303  			require.NoError(t, err)
   304  
   305  			if tt.args.postValidationFunc != nil {
   306  				tt.args.postValidationFunc(sha, path, c, tt.fields, tt.args)
   307  			}
   308  
   309  			require.NoError(t, gotCloser.Close())
   310  
   311  			exists, err = fileExists(path)
   312  			require.False(t, exists)
   313  			require.NoError(t, err)
   314  		})
   315  	}
   316  }
   317  
   318  func Test_nativeOCIClient_ResolveRevision(t *testing.T) {
   319  	store := memory.New()
   320  	data := []byte("")
   321  	descriptor := imagev1.Descriptor{
   322  		MediaType: "",
   323  		Digest:    digest.FromBytes(data),
   324  	}
   325  	require.NoError(t, store.Push(t.Context(), descriptor, bytes.NewReader(data)))
   326  	require.NoError(t, store.Tag(t.Context(), descriptor, "latest"))
   327  	require.NoError(t, store.Tag(t.Context(), descriptor, "1.2.0"))
   328  	require.NoError(t, store.Tag(t.Context(), descriptor, "v1.2.0"))
   329  	require.NoError(t, store.Tag(t.Context(), descriptor, descriptor.Digest.String()))
   330  
   331  	type fields struct {
   332  		repoURL           string
   333  		repo              oras.ReadOnlyTarget
   334  		tagsFunc          func(context.Context, string) (tags []string, err error)
   335  		allowedMediaTypes []string
   336  	}
   337  	tests := []struct {
   338  		name           string
   339  		fields         fields
   340  		revision       string
   341  		noCache        bool
   342  		expectedDigest string
   343  		expectedError  error
   344  	}{
   345  		{
   346  			name:     "resolve semantic version constraint",
   347  			revision: "^1.0.0",
   348  			fields: fields{repo: store, tagsFunc: func(context.Context, string) (tags []string, err error) {
   349  				return []string{"1.0.0", "1.1.0", "1.2.0", "2.0.0"}, nil
   350  			}},
   351  			expectedDigest: descriptor.Digest.String(),
   352  		},
   353  		{
   354  			name:     "resolve exact version",
   355  			revision: "1.2.0",
   356  			fields: fields{repo: store, tagsFunc: func(context.Context, string) (tags []string, err error) {
   357  				return []string{"1.0.0", "1.1.0", "1.2.0", "2.0.0"}, nil
   358  			}},
   359  			expectedDigest: descriptor.Digest.String(),
   360  		},
   361  		{
   362  			name:     "resolve digest directly",
   363  			revision: descriptor.Digest.String(),
   364  			fields: fields{repo: store, tagsFunc: func(context.Context, string) (tags []string, err error) {
   365  				return []string{}, errors.New("this should not be invoked")
   366  			}},
   367  			expectedDigest: descriptor.Digest.String(),
   368  		},
   369  		{
   370  			name:     "no matching version for constraint",
   371  			revision: "^3.0.0",
   372  			fields: fields{repo: store, tagsFunc: func(context.Context, string) (tags []string, err error) {
   373  				return []string{"1.0.0", "1.1.0", "1.2.0", "2.0.0"}, nil
   374  			}},
   375  			expectedError: errors.New("no version for constraints: version matching constraint not found in 4 tags"),
   376  		},
   377  		{
   378  			name:     "error fetching tags",
   379  			revision: "^1.0.0",
   380  			fields: fields{repo: store, tagsFunc: func(context.Context, string) (tags []string, err error) {
   381  				return []string{}, errors.New("some random error")
   382  			}},
   383  			expectedError: errors.New("error fetching tags: failed to get tags: some random error"),
   384  		},
   385  		{
   386  			name:     "error resolving digest",
   387  			revision: "sha256:abc123",
   388  			fields: fields{repo: store, tagsFunc: func(context.Context, string) (tags []string, err error) {
   389  				return []string{"1.0.0", "1.1.0", "1.2.0", "2.0.0"}, nil
   390  			}},
   391  			expectedError: errors.New("cannot get digest for revision sha256:abc123: sha256:abc123: not found"),
   392  		},
   393  		{
   394  			name:     "resolve latest tag",
   395  			revision: "latest",
   396  			fields: fields{repo: store, tagsFunc: func(context.Context, string) (tags []string, err error) {
   397  				return []string{"1.0.0", "1.1.0", "1.2.0", "2.0.0", "latest"}, nil
   398  			}},
   399  			expectedDigest: descriptor.Digest.String(),
   400  		},
   401  		{
   402  			name:     "resolve with complex semver constraint",
   403  			revision: ">=1.0.0 <2.0.0",
   404  			fields: fields{repo: store, tagsFunc: func(context.Context, string) (tags []string, err error) {
   405  				return []string{"0.9.0", "1.0.0", "1.1.0", "1.2.0", "2.0.0", "2.1.0"}, nil
   406  			}},
   407  			expectedDigest: descriptor.Digest.String(),
   408  		},
   409  		{
   410  			name:     "resolve with only non-semver tags",
   411  			revision: "^1.0.0",
   412  			fields: fields{repo: store, tagsFunc: func(context.Context, string) (tags []string, err error) {
   413  				return []string{"latest", "stable", "prod", "dev"}, nil
   414  			}},
   415  			expectedError: errors.New("no version for constraints: version matching constraint not found in 4 tags"),
   416  		},
   417  		{
   418  			name:     "resolve explicit tag",
   419  			revision: "v1.2.0",
   420  			fields: fields{repo: store, tagsFunc: func(context.Context, string) (tags []string, err error) {
   421  				return []string{}, errors.New("this should not be invoked")
   422  			}},
   423  			expectedError:  nil,
   424  			expectedDigest: descriptor.Digest.String(),
   425  		},
   426  		{
   427  			name:     "resolve with empty tag list",
   428  			revision: "^1.0.0",
   429  			fields: fields{repo: store, tagsFunc: func(context.Context, string) (tags []string, err error) {
   430  				return []string{}, nil
   431  			}},
   432  			expectedError: errors.New("no version for constraints: version matching constraint not found in 0 tags"),
   433  		},
   434  	}
   435  	for _, tt := range tests {
   436  		t.Run(tt.name, func(t *testing.T) {
   437  			c := newClientWithLock(tt.fields.repoURL, globalLock, tt.fields.repo, tt.fields.tagsFunc, func(_ context.Context) error {
   438  				return nil
   439  			}, tt.fields.allowedMediaTypes)
   440  			got, err := c.ResolveRevision(t.Context(), tt.revision, tt.noCache)
   441  			if tt.expectedError != nil {
   442  				require.EqualError(t, err, tt.expectedError.Error())
   443  				return
   444  			}
   445  
   446  			require.NoError(t, err)
   447  			if got != tt.expectedDigest {
   448  				t.Errorf("ResolveRevision() got = %v, expectedDigest %v", got, tt.expectedDigest)
   449  			}
   450  		})
   451  	}
   452  }