mirror of
https://github.com/argoproj/argo-cd.git
synced 2026-02-20 01:28:45 +01:00
fix: allow docker dhi helm charts to be used (#25835)
Signed-off-by: Blake Pettersson <blake.pettersson@gmail.com>
This commit is contained in:
@@ -44,6 +44,11 @@ var (
|
||||
indexLock = sync.NewKeyLock()
|
||||
)
|
||||
|
||||
const (
|
||||
helmOCIConfigType = "application/vnd.cncf.helm.config.v1+json"
|
||||
helmOCILayerType = "application/vnd.cncf.helm.chart.content.v1.tar+gzip"
|
||||
)
|
||||
|
||||
var _ Client = &nativeOCIClient{}
|
||||
|
||||
type tagsCache interface {
|
||||
@@ -260,6 +265,8 @@ func (c *nativeOCIClient) extract(ctx context.Context, digest string) (string, u
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
var isHelmChart bool
|
||||
|
||||
if !exists {
|
||||
ociManifest, err := getOCIManifest(ctx, digest, c.repo)
|
||||
if err != nil {
|
||||
@@ -272,16 +279,25 @@ func (c *nativeOCIClient) extract(ctx context.Context, digest string) (string, u
|
||||
return "", nil, fmt.Errorf("expected no more than 10 oci layers, got %d", len(ociManifest.Layers))
|
||||
}
|
||||
|
||||
isHelmChart = ociManifest.Config.MediaType == helmOCIConfigType
|
||||
|
||||
contentLayers := 0
|
||||
|
||||
// Strictly speaking we only allow for a single content layer. There are images which contains extra layers, such
|
||||
// as provenance/attestation layers. Pending a better story to do this natively, we will skip such layers for now.
|
||||
for _, layer := range ociManifest.Layers {
|
||||
if isContentLayer(layer.MediaType) {
|
||||
// For Helm charts, only look for the specific Helm chart content layer
|
||||
if isHelmChart {
|
||||
if isHelmOCI(layer.MediaType) {
|
||||
if !slices.Contains(c.allowedMediaTypes, layer.MediaType) {
|
||||
return "", nil, fmt.Errorf("oci layer media type %s is not in the list of allowed media types", layer.MediaType)
|
||||
}
|
||||
contentLayers++
|
||||
}
|
||||
} else if isContentLayer(layer.MediaType) {
|
||||
if !slices.Contains(c.allowedMediaTypes, layer.MediaType) {
|
||||
return "", nil, fmt.Errorf("oci layer media type %s is not in the list of allowed media types", layer.MediaType)
|
||||
}
|
||||
|
||||
contentLayers++
|
||||
}
|
||||
}
|
||||
@@ -301,7 +317,15 @@ func (c *nativeOCIClient) extract(ctx context.Context, digest string) (string, u
|
||||
maxSize = math.MaxInt64
|
||||
}
|
||||
|
||||
manifestsDir, err := extractContentToManifestsDir(ctx, cachedPath, digest, maxSize)
|
||||
if !isHelmChart {
|
||||
// Get the manifest to determine if it's a Helm chart for extraction
|
||||
ociManifest, err := getOCIManifestFromCache(ctx, cachedPath, digest)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("error getting oci manifest for extraction: %w", err)
|
||||
}
|
||||
isHelmChart = ociManifest.Config.MediaType == helmOCIConfigType
|
||||
}
|
||||
manifestsDir, err := extractContentToManifestsDir(ctx, cachedPath, digest, maxSize, isHelmChart)
|
||||
if err != nil {
|
||||
return manifestsDir, nil, fmt.Errorf("cannot extract contents of oci image with revision %s: %w", digest, err)
|
||||
}
|
||||
@@ -345,13 +369,7 @@ func (c *nativeOCIClient) digestMetadata(ctx context.Context, digest string) (*i
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error fetching oci metadata path for digest %s: %w", digest, err)
|
||||
}
|
||||
|
||||
repo, err := oci.NewFromTar(ctx, path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error extracting oci image for digest %s: %w", digest, err)
|
||||
}
|
||||
|
||||
return getOCIManifest(ctx, digest, repo)
|
||||
return getOCIManifestFromCache(ctx, path, digest)
|
||||
}
|
||||
|
||||
func (c *nativeOCIClient) ResolveRevision(ctx context.Context, revision string, noCache bool) (string, error) {
|
||||
@@ -543,8 +561,8 @@ func saveCompressedImageToPath(ctx context.Context, digest string, repo oras.Rea
|
||||
}
|
||||
|
||||
// extractContentToManifestsDir looks up a locally stored OCI image, and extracts the embedded compressed layer which contains
|
||||
// K8s manifests to a temporary directory
|
||||
func extractContentToManifestsDir(ctx context.Context, cachedPath, digest string, maxSize int64) (string, error) {
|
||||
// K8s manifests to a temp dir.
|
||||
func extractContentToManifestsDir(ctx context.Context, cachedPath, digest string, maxSize int64, isHelmChart bool) (string, error) {
|
||||
manifestsDir, err := files.CreateTempDir(os.TempDir())
|
||||
if err != nil {
|
||||
return manifestsDir, err
|
||||
@@ -561,7 +579,7 @@ func extractContentToManifestsDir(ctx context.Context, cachedPath, digest string
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
fs, err := newCompressedLayerFileStore(manifestsDir, tempDir, maxSize)
|
||||
fs, err := newCompressedLayerFileStore(manifestsDir, tempDir, maxSize, isHelmChart)
|
||||
if err != nil {
|
||||
return manifestsDir, err
|
||||
}
|
||||
@@ -574,26 +592,32 @@ func extractContentToManifestsDir(ctx context.Context, cachedPath, digest string
|
||||
|
||||
type compressedLayerExtracterStore struct {
|
||||
*file.Store
|
||||
dest string
|
||||
maxSize int64
|
||||
dest string
|
||||
maxSize int64
|
||||
isHelmChart bool
|
||||
}
|
||||
|
||||
func newCompressedLayerFileStore(dest, tempDir string, maxSize int64) (*compressedLayerExtracterStore, error) {
|
||||
func newCompressedLayerFileStore(dest, tempDir string, maxSize int64, isHelmChart bool) (*compressedLayerExtracterStore, error) {
|
||||
f, err := file.New(tempDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &compressedLayerExtracterStore{f, dest, maxSize}, nil
|
||||
return &compressedLayerExtracterStore{f, dest, maxSize, isHelmChart}, nil
|
||||
}
|
||||
|
||||
func isHelmOCI(mediaType string) bool {
|
||||
return mediaType == "application/vnd.cncf.helm.chart.content.v1.tar+gzip"
|
||||
return mediaType == helmOCILayerType
|
||||
}
|
||||
|
||||
// Push looks in all the layers of an OCI image. Once it finds a layer that is compressed, it extracts the layer to a tempDir
|
||||
// and then renames the temp dir to the directory where the repo-server expects to find k8s manifests.
|
||||
func (s *compressedLayerExtracterStore) Push(ctx context.Context, desc imagev1.Descriptor, content io.Reader) error {
|
||||
// For Helm charts, only extract the Helm chart content layer, skip all other layers
|
||||
if s.isHelmChart && !isHelmOCI(desc.MediaType) {
|
||||
return s.Store.Push(ctx, desc, content)
|
||||
}
|
||||
|
||||
if isContentLayer(desc.MediaType) {
|
||||
srcDir, err := files.CreateTempDir(os.TempDir())
|
||||
if err != nil {
|
||||
@@ -682,6 +706,15 @@ func getOCIManifest(ctx context.Context, digest string, repo oras.ReadOnlyTarget
|
||||
return &manifest, nil
|
||||
}
|
||||
|
||||
// getOCIManifestFromCache retrieves an OCI manifest from a cached tar file
|
||||
func getOCIManifestFromCache(ctx context.Context, cachedPath, digest string) (*imagev1.Manifest, error) {
|
||||
repo, err := oci.NewFromTar(ctx, cachedPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating oci store from cache: %w", err)
|
||||
}
|
||||
return getOCIManifest(ctx, digest, repo)
|
||||
}
|
||||
|
||||
// WithEventHandlers sets the git client event handlers
|
||||
func WithEventHandlers(handlers EventHandlers) ClientOpts {
|
||||
return func(c *nativeOCIClient) {
|
||||
|
||||
@@ -30,9 +30,14 @@ type layerConf struct {
|
||||
}
|
||||
|
||||
func generateManifest(t *testing.T, store *memory.Store, layerDescs ...layerConf) string {
|
||||
t.Helper()
|
||||
return generateManifestWithConfig(t, store, imagev1.MediaTypeImageConfig, layerDescs...)
|
||||
}
|
||||
|
||||
func generateManifestWithConfig(t *testing.T, store *memory.Store, configMediaType string, layerDescs ...layerConf) string {
|
||||
t.Helper()
|
||||
configBlob := []byte("Hello config")
|
||||
configDesc := content.NewDescriptorFromBytes(imagev1.MediaTypeImageConfig, configBlob)
|
||||
configDesc := content.NewDescriptorFromBytes(configMediaType, configBlob)
|
||||
|
||||
var layers []imagev1.Descriptor
|
||||
|
||||
@@ -281,6 +286,278 @@ func Test_nativeOCIClient_Extract(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "helm chart with multiple layers (provenance + chart content) should succeed",
|
||||
fields: fields{
|
||||
allowedMediaTypes: []string{"application/vnd.cncf.helm.chart.content.v1.tar+gzip"},
|
||||
},
|
||||
args: args{
|
||||
digestFunc: func(store *memory.Store) string {
|
||||
chartDir := t.TempDir()
|
||||
chartName := "mychart"
|
||||
|
||||
parent := filepath.Join(chartDir, "parent")
|
||||
require.NoError(t, os.Mkdir(parent, 0o755))
|
||||
|
||||
chartPath := filepath.Join(parent, chartName)
|
||||
require.NoError(t, os.Mkdir(chartPath, 0o755))
|
||||
|
||||
addFileToDirectory(t, chartPath, "Chart.yaml", "helm chart content")
|
||||
|
||||
temp, err := os.CreateTemp(t.TempDir(), "")
|
||||
require.NoError(t, err)
|
||||
defer temp.Close()
|
||||
_, err = files.Tgz(parent, nil, nil, temp)
|
||||
require.NoError(t, err)
|
||||
_, err = temp.Seek(0, io.SeekStart)
|
||||
require.NoError(t, err)
|
||||
chartBlob, err := io.ReadAll(temp)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create provenance layer
|
||||
provenanceBlob := []byte("provenance data")
|
||||
|
||||
return generateManifestWithConfig(t, store, "application/vnd.cncf.helm.config.v1+json",
|
||||
layerConf{content.NewDescriptorFromBytes("application/vnd.cncf.helm.chart.content.v1.tar+gzip", chartBlob), chartBlob},
|
||||
layerConf{content.NewDescriptorFromBytes("application/vnd.cncf.helm.chart.provenance.v1.prov", provenanceBlob), provenanceBlob})
|
||||
},
|
||||
postValidationFunc: func(_, path string, _ Client, _ fields, _ args) {
|
||||
// Verify only chart content was extracted, not provenance
|
||||
chartDir, err := os.ReadDir(path)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, chartDir, 1)
|
||||
require.Equal(t, "Chart.yaml", chartDir[0].Name())
|
||||
|
||||
chartYaml, err := os.Open(filepath.Join(path, chartDir[0].Name()))
|
||||
require.NoError(t, err)
|
||||
contents, err := io.ReadAll(chartYaml)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "helm chart content", string(contents))
|
||||
},
|
||||
manifestMaxExtractedSize: 10000,
|
||||
disableManifestMaxExtractedSize: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "helm chart with multiple layers (attestation + provenance + chart content) should succeed",
|
||||
fields: fields{
|
||||
allowedMediaTypes: []string{"application/vnd.cncf.helm.chart.content.v1.tar+gzip"},
|
||||
},
|
||||
args: args{
|
||||
digestFunc: func(store *memory.Store) string {
|
||||
chartDir := t.TempDir()
|
||||
chartName := "mychart"
|
||||
|
||||
parent := filepath.Join(chartDir, "parent")
|
||||
require.NoError(t, os.Mkdir(parent, 0o755))
|
||||
|
||||
chartPath := filepath.Join(parent, chartName)
|
||||
require.NoError(t, os.Mkdir(chartPath, 0o755))
|
||||
|
||||
addFileToDirectory(t, chartPath, "Chart.yaml", "multi-layer chart")
|
||||
addFileToDirectory(t, chartPath, "values.yaml", "key: value")
|
||||
|
||||
temp, err := os.CreateTemp(t.TempDir(), "")
|
||||
require.NoError(t, err)
|
||||
defer temp.Close()
|
||||
_, err = files.Tgz(parent, nil, nil, temp)
|
||||
require.NoError(t, err)
|
||||
_, err = temp.Seek(0, io.SeekStart)
|
||||
require.NoError(t, err)
|
||||
chartBlob, err := io.ReadAll(temp)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create multiple non-content layers
|
||||
attestationBlob := []byte("attestation data")
|
||||
provenanceBlob := []byte("provenance data")
|
||||
|
||||
return generateManifestWithConfig(t, store, "application/vnd.cncf.helm.config.v1+json",
|
||||
layerConf{content.NewDescriptorFromBytes("application/vnd.in-toto+json", attestationBlob), attestationBlob},
|
||||
layerConf{content.NewDescriptorFromBytes("application/vnd.cncf.helm.chart.content.v1.tar+gzip", chartBlob), chartBlob},
|
||||
layerConf{content.NewDescriptorFromBytes("application/vnd.cncf.helm.chart.provenance.v1.prov", provenanceBlob), provenanceBlob})
|
||||
},
|
||||
postValidationFunc: func(_, path string, _ Client, _ fields, _ args) {
|
||||
// Verify only chart content was extracted
|
||||
chartDir, err := os.ReadDir(path)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, chartDir, 2) // Chart.yaml and values.yaml
|
||||
|
||||
files := make(map[string]bool)
|
||||
for _, f := range chartDir {
|
||||
files[f.Name()] = true
|
||||
}
|
||||
require.True(t, files["Chart.yaml"])
|
||||
require.True(t, files["values.yaml"])
|
||||
|
||||
// Ensure no provenance or attestation files were extracted
|
||||
require.False(t, files["provenance"])
|
||||
require.False(t, files["attestation"])
|
||||
},
|
||||
manifestMaxExtractedSize: 10000,
|
||||
disableManifestMaxExtractedSize: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "helm chart with only provenance layer should fail (no chart content)",
|
||||
fields: fields{
|
||||
allowedMediaTypes: []string{"application/vnd.cncf.helm.chart.content.v1.tar+gzip"},
|
||||
},
|
||||
args: args{
|
||||
digestFunc: func(store *memory.Store) string {
|
||||
provenanceBlob := []byte("provenance data")
|
||||
return generateManifestWithConfig(t, store, "application/vnd.cncf.helm.config.v1+json",
|
||||
layerConf{content.NewDescriptorFromBytes("application/vnd.cncf.helm.chart.provenance.v1.prov", provenanceBlob), provenanceBlob})
|
||||
},
|
||||
manifestMaxExtractedSize: 1000,
|
||||
disableManifestMaxExtractedSize: false,
|
||||
},
|
||||
expectedError: errors.New("expected only a single oci content layer, got 0"),
|
||||
},
|
||||
{
|
||||
name: "non-helm OCI with multiple content layers should still fail",
|
||||
fields: fields{
|
||||
allowedMediaTypes: []string{imagev1.MediaTypeImageLayerGzip},
|
||||
},
|
||||
args: args{
|
||||
digestFunc: func(store *memory.Store) string {
|
||||
layerBlob1 := createGzippedTarWithContent(t, "file1.yaml", "content1")
|
||||
layerBlob2 := createGzippedTarWithContent(t, "file2.yaml", "content2")
|
||||
// Using standard image config, not Helm config
|
||||
return generateManifest(t, store,
|
||||
layerConf{content.NewDescriptorFromBytes(imagev1.MediaTypeImageLayerGzip, layerBlob1), layerBlob1},
|
||||
layerConf{content.NewDescriptorFromBytes(imagev1.MediaTypeImageLayerGzip, layerBlob2), layerBlob2})
|
||||
},
|
||||
manifestMaxExtractedSize: 1000,
|
||||
disableManifestMaxExtractedSize: false,
|
||||
},
|
||||
expectedError: errors.New("expected only a single oci content layer, got 2"),
|
||||
},
|
||||
{
|
||||
name: "helm chart with extra content layer should succeed and ignore extra layer",
|
||||
fields: fields{
|
||||
allowedMediaTypes: []string{"application/vnd.cncf.helm.chart.content.v1.tar+gzip", imagev1.MediaTypeImageLayerGzip},
|
||||
},
|
||||
args: args{
|
||||
digestFunc: func(store *memory.Store) string {
|
||||
chartDir := t.TempDir()
|
||||
chartName := "mychart"
|
||||
|
||||
parent := filepath.Join(chartDir, "parent")
|
||||
require.NoError(t, os.Mkdir(parent, 0o755))
|
||||
|
||||
chartPath := filepath.Join(parent, chartName)
|
||||
require.NoError(t, os.Mkdir(chartPath, 0o755))
|
||||
|
||||
addFileToDirectory(t, chartPath, "Chart.yaml", "chart with extra docker layer")
|
||||
|
||||
temp, err := os.CreateTemp(t.TempDir(), "")
|
||||
require.NoError(t, err)
|
||||
defer temp.Close()
|
||||
_, err = files.Tgz(parent, nil, nil, temp)
|
||||
require.NoError(t, err)
|
||||
_, err = temp.Seek(0, io.SeekStart)
|
||||
require.NoError(t, err)
|
||||
chartBlob, err := io.ReadAll(temp)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Extra OCI layer that Docker/some registries add
|
||||
extraLayerBlob := createGzippedTarWithContent(t, "extra.txt", "extra layer content")
|
||||
|
||||
// Helm chart with proper Helm content layer + extra OCI layer that should be ignored
|
||||
return generateManifestWithConfig(t, store, "application/vnd.cncf.helm.config.v1+json",
|
||||
layerConf{content.NewDescriptorFromBytes("application/vnd.cncf.helm.chart.content.v1.tar+gzip", chartBlob), chartBlob},
|
||||
layerConf{content.NewDescriptorFromBytes(imagev1.MediaTypeImageLayerGzip, extraLayerBlob), extraLayerBlob})
|
||||
},
|
||||
postValidationFunc: func(_, path string, _ Client, _ fields, _ args) {
|
||||
// Verify only Helm chart content was extracted, not the extra OCI layer
|
||||
chartDir, err := os.ReadDir(path)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, chartDir, 1)
|
||||
require.Equal(t, "Chart.yaml", chartDir[0].Name())
|
||||
|
||||
chartYaml, err := os.Open(filepath.Join(path, chartDir[0].Name()))
|
||||
require.NoError(t, err)
|
||||
contents, err := io.ReadAll(chartYaml)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "chart with extra docker layer", string(contents))
|
||||
},
|
||||
manifestMaxExtractedSize: 10000,
|
||||
disableManifestMaxExtractedSize: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "helm chart with extra OCI layer + provenance should extract only helm chart content",
|
||||
fields: fields{
|
||||
allowedMediaTypes: []string{"application/vnd.cncf.helm.chart.content.v1.tar+gzip", imagev1.MediaTypeImageLayerGzip},
|
||||
},
|
||||
args: args{
|
||||
digestFunc: func(store *memory.Store) string {
|
||||
chartDir := t.TempDir()
|
||||
chartName := "mychart"
|
||||
|
||||
parent := filepath.Join(chartDir, "parent")
|
||||
require.NoError(t, os.Mkdir(parent, 0o755))
|
||||
|
||||
chartPath := filepath.Join(parent, chartName)
|
||||
require.NoError(t, os.Mkdir(chartPath, 0o755))
|
||||
|
||||
templatesPath := filepath.Join(chartPath, "templates")
|
||||
require.NoError(t, os.Mkdir(templatesPath, 0o755))
|
||||
|
||||
addFileToDirectory(t, chartPath, "Chart.yaml", "multi-layer helm chart")
|
||||
addFileToDirectory(t, templatesPath, "deployment.yaml", "apiVersion: apps/v1")
|
||||
|
||||
temp, err := os.CreateTemp(t.TempDir(), "")
|
||||
require.NoError(t, err)
|
||||
defer temp.Close()
|
||||
_, err = files.Tgz(parent, nil, nil, temp)
|
||||
require.NoError(t, err)
|
||||
_, err = temp.Seek(0, io.SeekStart)
|
||||
require.NoError(t, err)
|
||||
chartBlob, err := io.ReadAll(temp)
|
||||
require.NoError(t, err)
|
||||
|
||||
provenanceBlob := []byte("provenance data")
|
||||
extraLayerBlob := createGzippedTarWithContent(t, "extra.txt", "extra oci layer")
|
||||
|
||||
// Helm chart with: Helm content layer + extra OCI layer + provenance
|
||||
// Only the Helm content layer should be extracted
|
||||
return generateManifestWithConfig(t, store, "application/vnd.cncf.helm.config.v1+json",
|
||||
layerConf{content.NewDescriptorFromBytes("application/vnd.cncf.helm.chart.content.v1.tar+gzip", chartBlob), chartBlob},
|
||||
layerConf{content.NewDescriptorFromBytes(imagev1.MediaTypeImageLayerGzip, extraLayerBlob), extraLayerBlob},
|
||||
layerConf{content.NewDescriptorFromBytes("application/vnd.cncf.helm.chart.provenance.v1.prov", provenanceBlob), provenanceBlob})
|
||||
},
|
||||
postValidationFunc: func(_, path string, _ Client, _ fields, _ args) {
|
||||
// Verify only Helm chart content was extracted
|
||||
entries, err := os.ReadDir(path)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, 2) // Chart.yaml and templates dir
|
||||
|
||||
files := make(map[string]bool)
|
||||
for _, e := range entries {
|
||||
files[e.Name()] = true
|
||||
}
|
||||
require.True(t, files["Chart.yaml"])
|
||||
require.True(t, files["templates"])
|
||||
|
||||
// Verify Chart.yaml content
|
||||
chartYaml, err := os.ReadFile(filepath.Join(path, "Chart.yaml"))
|
||||
require.NoError(t, err)
|
||||
require.YAMLEq(t, "multi-layer helm chart", string(chartYaml))
|
||||
|
||||
// Verify templates/deployment.yaml exists
|
||||
deploymentYaml, err := os.ReadFile(filepath.Join(path, "templates", "deployment.yaml"))
|
||||
require.NoError(t, err)
|
||||
require.YAMLEq(t, "apiVersion: apps/v1", string(deploymentYaml))
|
||||
|
||||
// Ensure extra OCI layer and provenance were not extracted
|
||||
require.False(t, files["extra.txt"])
|
||||
require.False(t, files["provenance"])
|
||||
},
|
||||
manifestMaxExtractedSize: 10000,
|
||||
disableManifestMaxExtractedSize: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
Reference in New Issue
Block a user