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 }