fix: allow docker dhi helm charts to be used (#25835)

Signed-off-by: Blake Pettersson <blake.pettersson@gmail.com>
This commit is contained in:
Blake Pettersson
2026-01-13 08:07:30 -10:00
committed by GitHub
parent 6a3a540c9a
commit 1488a13b89
2 changed files with 329 additions and 19 deletions

View File

@@ -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) {

View File

@@ -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 {