diff --git a/internal/operator-controller/applier/helm.go b/internal/operator-controller/applier/helm.go index cc47cc5a3..bf9f9e4a2 100644 --- a/internal/operator-controller/applier/helm.go +++ b/internal/operator-controller/applier/helm.go @@ -26,9 +26,11 @@ import ( ocv1 "github.com/operator-framework/operator-controller/api/v1" "github.com/operator-framework/operator-controller/internal/operator-controller/authorization" + "github.com/operator-framework/operator-controller/internal/operator-controller/features" "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/bundle/source" "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/preflights/crdupgradesafety" "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/util" + imageutil "github.com/operator-framework/operator-controller/internal/shared/util/image" ) const ( @@ -209,6 +211,17 @@ func (h *Helm) buildHelmChart(bundleFS fs.FS, ext *ocv1.ClusterExtension) (*char if err != nil { return nil, err } + if features.OperatorControllerFeatureGate.Enabled(features.HelmChartSupport) { + meta := new(chart.Metadata) + if ok, _ := imageutil.IsBundleSourceChart(bundleFS, meta); ok { + return imageutil.LoadChartFSWithOptions( + bundleFS, + fmt.Sprintf("%s-%s.tgz", meta.Name, meta.Version), + imageutil.WithInstallNamespace(ext.Spec.Namespace), + ) + } + } + return h.BundleToHelmChartConverter.ToHelmChart(source.FromFS(bundleFS), ext.Spec.Namespace, watchNamespace) } diff --git a/internal/operator-controller/features/features.go b/internal/operator-controller/features/features.go index 1de30e25b..41bad3cf7 100644 --- a/internal/operator-controller/features/features.go +++ b/internal/operator-controller/features/features.go @@ -16,6 +16,7 @@ const ( SyntheticPermissions featuregate.Feature = "SyntheticPermissions" WebhookProviderCertManager featuregate.Feature = "WebhookProviderCertManager" WebhookProviderOpenshiftServiceCA featuregate.Feature = "WebhookProviderOpenshiftServiceCA" + HelmChartSupport featuregate.Feature = "HelmChartSupport" ) var operatorControllerFeatureGates = map[featuregate.Feature]featuregate.FeatureSpec{ @@ -63,6 +64,14 @@ var operatorControllerFeatureGates = map[featuregate.Feature]featuregate.Feature PreRelease: featuregate.Alpha, LockToDefault: false, }, + + // HelmChartSupport enables support for installing, + // updating and uninstalling Helm Charts via Cluster Extensions. + HelmChartSupport: { + Default: false, + PreRelease: featuregate.Alpha, + LockToDefault: false, + }, } var OperatorControllerFeatureGate featuregate.MutableFeatureGate = featuregate.NewFeatureGate() diff --git a/internal/shared/util/image/cache.go b/internal/shared/util/image/cache.go index fbbb52bd8..6ea6eeaf5 100644 --- a/internal/shared/util/image/cache.go +++ b/internal/shared/util/image/cache.go @@ -16,6 +16,8 @@ import ( "github.com/containers/image/v5/docker/reference" "github.com/opencontainers/go-digest" ocispecv1 "github.com/opencontainers/image-spec/specs-go/v1" + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/registry" "sigs.k8s.io/controller-runtime/pkg/log" errorutil "github.com/operator-framework/operator-controller/internal/shared/util/error" @@ -23,9 +25,10 @@ import ( ) type LayerData struct { - Reader io.Reader - Index int - Err error + MediaType string + Reader io.Reader + Index int + Err error } type Cache interface { @@ -128,8 +131,15 @@ func (a *diskCache) Store(ctx context.Context, ownerID string, srcRef reference. if layer.Err != nil { return fmt.Errorf("error reading layer[%d]: %w", layer.Index, layer.Err) } - if _, err := archive.Apply(ctx, dest, layer.Reader, applyOpts...); err != nil { - return fmt.Errorf("error applying layer[%d]: %w", layer.Index, err) + switch layer.MediaType { + case registry.ChartLayerMediaType: + if err := storeChartLayer(dest, layer); err != nil { + return err + } + default: + if _, err := archive.Apply(ctx, dest, layer.Reader, applyOpts...); err != nil { + return fmt.Errorf("error applying layer[%d]: %w", layer.Index, err) + } } l.Info("applied layer", "layer", layer.Index) } @@ -147,6 +157,29 @@ func (a *diskCache) Store(ctx context.Context, ownerID string, srcRef reference. return os.DirFS(dest), modTime, nil } +func storeChartLayer(path string, layer LayerData) error { + data, err := io.ReadAll(layer.Reader) + if err != nil { + return fmt.Errorf("error reading layer[%d]: %w", layer.Index, layer.Err) + } + meta := new(chart.Metadata) + _, err = inspectChart(data, meta) + if err != nil { + return fmt.Errorf("inspecting chart layer: %w", err) + } + filename := filepath.Join(path, + fmt.Sprintf("%s-%s.tgz", meta.Name, meta.Version), + ) + chart, err := os.Create(filename) + if err != nil { + return fmt.Errorf("inspecting chart layer: %w", err) + } + defer chart.Close() + + _, err = chart.Write(data) + return err +} + func (a *diskCache) Delete(_ context.Context, ownerID string) error { return fsutil.DeleteReadOnlyRecursive(a.ownerIDPath(ownerID)) } diff --git a/internal/shared/util/image/cache_test.go b/internal/shared/util/image/cache_test.go index a5b644feb..6eaa3d8af 100644 --- a/internal/shared/util/image/cache_test.go +++ b/internal/shared/util/image/cache_test.go @@ -2,6 +2,7 @@ package image import ( "archive/tar" + "bytes" "context" "errors" "io" @@ -20,6 +21,7 @@ import ( ocispecv1 "github.com/opencontainers/image-spec/specs-go/v1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "helm.sh/helm/v3/pkg/registry" fsutil "github.com/operator-framework/operator-controller/internal/shared/util/fs" ) @@ -211,6 +213,22 @@ func TestDiskCacheStore(t *testing.T) { assert.ErrorContains(t, err, "error applying layer") }, }, + { + name: "returns no error if layer read contains helm chart", + ownerID: myOwner, + srcRef: myTaggedRef, + canonicalRef: myCanonicalRef, + layers: func() iter.Seq[LayerData] { + sampleChart := filepath.Join("../../../../", "testdata", "charts", "sample-chart-0.1.0.tgz") + data, _ := os.ReadFile(sampleChart) + return func(yield func(LayerData) bool) { + yield(LayerData{Reader: bytes.NewBuffer(data), MediaType: registry.ChartLayerMediaType}) + } + }(), + expect: func(t *testing.T, cache *diskCache, fsys fs.FS, modTime time.Time, err error) { + require.NoError(t, err) + }, + }, { name: "no error and an empty FS returned when there are no layers", ownerID: myOwner, diff --git a/internal/shared/util/image/helm.go b/internal/shared/util/image/helm.go new file mode 100644 index 000000000..db37b7c53 --- /dev/null +++ b/internal/shared/util/image/helm.go @@ -0,0 +1,222 @@ +package image + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "iter" + "os" + "path/filepath" + "regexp" + "slices" + "strings" + "time" + + "github.com/containers/image/v5/docker/reference" + "github.com/containers/image/v5/types" + ocispecv1 "github.com/opencontainers/image-spec/specs-go/v1" + "gopkg.in/yaml.v2" + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chart/loader" + "helm.sh/helm/v3/pkg/registry" +) + +func hasChart(imgCloser types.ImageCloser) bool { + config := imgCloser.ConfigInfo() + return config.MediaType == registry.ConfigMediaType +} + +func pullChart(ctx context.Context, ownerID string, srcRef reference.Named, canonicalRef reference.Canonical, imgSrc types.ImageSource, imgRef types.ImageReference, cache Cache) (fs.FS, time.Time, error) { + imgDigest := canonicalRef.Digest() + raw, _, err := imgSrc.GetManifest(ctx, &imgDigest) + if err != nil { + return nil, time.Time{}, fmt.Errorf("get OCI helm chart manifest; %w", err) + } + + chartManifest := ocispecv1.Manifest{} + if err := json.Unmarshal(raw, &chartManifest); err != nil { + return nil, time.Time{}, fmt.Errorf("unmarshaling chart manifest; %w", err) + } + + if len(chartManifest.Layers) == 0 { + return nil, time.Time{}, fmt.Errorf("manifest has no layers; expected at least one chart layer") + } + + layerIter := iter.Seq[LayerData](func(yield func(LayerData) bool) { + for i, layer := range chartManifest.Layers { + ld := LayerData{Index: i, MediaType: layer.MediaType} + if layer.MediaType == registry.ChartLayerMediaType { + var contents []byte + contents, ld.Err = os.ReadFile(filepath.Join( + imgRef.PolicyConfigurationIdentity(), "blobs", + "sha256", chartManifest.Layers[i].Digest.Encoded()), + ) + ld.Reader = bytes.NewBuffer(contents) + } + // Ignore the Helm provenance data layer + if layer.MediaType == registry.ProvLayerMediaType { + continue + } + if !yield(ld) { + return + } + } + }) + + return cache.Store(ctx, ownerID, srcRef, canonicalRef, ocispecv1.Image{}, layerIter) +} + +func IsValidChart(chart *chart.Chart) error { + if chart.Metadata == nil { + return errors.New("chart metadata is missing") + } + if chart.Metadata.Name == "" { + return errors.New("chart name is required") + } + if chart.Metadata.Version == "" { + return errors.New("chart version is required") + } + return chart.Metadata.Validate() +} + +type chartInspectionResult struct { + // templatesExist is set to true if the templates + // directory exists in the chart archive + templatesExist bool + // chartfileExists is set to true if the Chart.yaml + // file exists in the chart archive + chartfileExists bool +} + +func inspectChart(data []byte, metadata *chart.Metadata) (chartInspectionResult, error) { + gzReader, err := gzip.NewReader(bytes.NewReader(data)) + if err != nil { + return chartInspectionResult{}, err + } + defer gzReader.Close() + + report := chartInspectionResult{} + tarReader := tar.NewReader(gzReader) + for { + header, err := tarReader.Next() + if err == io.EOF { + if !report.chartfileExists && !report.templatesExist { + return report, errors.New("neither Chart.yaml nor templates directory were found") + } + + if !report.chartfileExists { + return report, errors.New("the Chart.yaml file was not found") + } + + if !report.templatesExist { + return report, errors.New("templates directory not found") + } + + return report, nil + } + + if strings.HasSuffix(header.Name, filepath.Join("templates", filepath.Base(header.Name))) { + report.templatesExist = true + } + + if filepath.Base(header.Name) == "Chart.yaml" { + report.chartfileExists = true + if err := loadMetadataArchive(tarReader, metadata); err != nil { + return report, err + } + } + } +} + +func loadMetadataArchive(r io.Reader, metadata *chart.Metadata) error { + if metadata == nil { + return nil + } + + content, err := io.ReadAll(r) + if err != nil { + return fmt.Errorf("reading Chart.yaml; %w", err) + } + + if err := yaml.Unmarshal(content, metadata); err != nil { + return fmt.Errorf("unmarshaling Chart.yaml; %w", err) + } + + return nil +} + +func IsBundleSourceChart(bundleFS fs.FS, metadata *chart.Metadata) (bool, error) { + var chartPath string + files, _ := fs.ReadDir(bundleFS, ".") + for _, file := range files { + if slices.Contains([]string{".tar.gz", ".tgz"}, filepath.Ext(file.Name())) { + chartPath = file.Name() + break + } + } + + chartData, err := fs.ReadFile(bundleFS, chartPath) + if err != nil { + return false, err + } + + result, err := inspectChart(chartData, metadata) + if err != nil { + return false, err + } + + return (result.templatesExist && result.chartfileExists), nil +} + +type ChartOption func(*chart.Chart) + +func WithInstallNamespace(namespace string) ChartOption { + re := regexp.MustCompile(`{{\W+\.Release\.Namespace\W+}}`) + + return func(chrt *chart.Chart) { + for i, template := range chrt.Templates { + chrt.Templates[i].Data = re.ReplaceAll(template.Data, []byte(namespace)) + } + } +} + +func LoadChartFSWithOptions(bundleFS fs.FS, filename string, options ...ChartOption) (*chart.Chart, error) { + ch, err := loadChartFS(bundleFS, filename) + if err != nil { + return nil, err + } + + return enrichChart(ch, options...) +} + +func enrichChart(chart *chart.Chart, options ...ChartOption) (*chart.Chart, error) { + if chart == nil { + return nil, fmt.Errorf("chart can not be nil") + } + for _, f := range options { + f(chart) + } + return chart, nil +} + +var LoadChartFS = loadChartFS + +// loadChartFS loads a chart archive from a filesystem of +// type fs.FS with the provided filename +func loadChartFS(bundleFS fs.FS, filename string) (*chart.Chart, error) { + if filename == "" { + return nil, fmt.Errorf("chart file name was not provided") + } + + tarball, err := fs.ReadFile(bundleFS, filename) + if err != nil { + return nil, fmt.Errorf("reading chart %s; %+v", filename, err) + } + return loader.LoadArchive(bytes.NewBuffer(tarball)) +} diff --git a/internal/shared/util/image/helm_test.go b/internal/shared/util/image/helm_test.go new file mode 100644 index 000000000..f8f63811e --- /dev/null +++ b/internal/shared/util/image/helm_test.go @@ -0,0 +1,449 @@ +package image + +import ( + "context" + "fmt" + "io" + "io/fs" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "reflect" + "testing" + "time" + + "github.com/containerd/containerd/archive" + "github.com/containers/image/v5/docker/reference" + "github.com/containers/image/v5/types" + goregistry "github.com/google/go-containerregistry/pkg/registry" + "github.com/opencontainers/go-digest" + ocispecv1 "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/registry" + + fsutil "github.com/operator-framework/operator-controller/internal/shared/util/fs" +) + +func Test_pullChart(t *testing.T) { + const myOwner = "myOwner" + myChartName := "sample-chart-0.1.0.tgz" + sampleChartPath := filepath.Join("../../../../", "testdata", "charts", "sample-chart-0.1.0.tgz") + + myTagRef, myCanonicalRef, cleanup := setupChartRegistry(t, sampleChartPath) + defer cleanup() + + tests := []struct { + name string + ownerID string + srcRef string + cache Cache + contextFunc func(context.Context) (*types.SystemContext, error) + expect func(*testing.T, fs.FS, reference.Canonical, time.Time, error) + }{ + { + name: "pull helm chart from OCI registry", + ownerID: myOwner, + srcRef: myTagRef.String(), + cache: &diskCache{ + basePath: t.TempDir(), + filterFunc: func(ctx context.Context, named reference.Named, image ocispecv1.Image) (archive.Filter, error) { + return forceOwnershipRWX(), nil + }, + }, + contextFunc: buildSourceContextFunc(t, myTagRef), + expect: func(t *testing.T, fsys fs.FS, canonical reference.Canonical, modTime time.Time, err error) { + require.NoError(t, err) + + actualChartData, err := fs.ReadFile(fsys, myChartName) + require.NoError(t, err) + + chartData, err := os.ReadFile(sampleChartPath) + require.NoError(t, err) + + assert.Equal(t, chartData, actualChartData) + + assert.Equal(t, myCanonicalRef.String(), canonical.String()) + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + puller := ContainersImagePuller{ + SourceCtxFunc: tc.contextFunc, + } + fsys, canonicalRef, modTime, err := puller.Pull(context.Background(), tc.ownerID, tc.srcRef, tc.cache) + require.NotNil(t, tc.expect, "expect function must be defined") + + tc.expect(t, fsys, canonicalRef, modTime, err) + + if dc, ok := tc.cache.(*diskCache); ok && dc.basePath != "" { + require.NoError(t, fsutil.DeleteReadOnlyRecursive(dc.basePath)) + } + }) + } +} + +func TestIsValidChart(t *testing.T) { + tt := []struct { + name string + target *chart.Chart + wantErr bool + errMsg string + }{ + { + name: "helm chart with required metadata", + target: &chart.Chart{ + Metadata: &chart.Metadata{ + APIVersion: "v2", + Name: "sample-chart", + Version: "0.1.2", + }, + }, + wantErr: false, + }, + { + name: "helm chart without name", + target: &chart.Chart{ + Metadata: &chart.Metadata{ + APIVersion: "v2", + Name: "", + Version: "0.1.2", + }, + }, + wantErr: true, + errMsg: "chart name is required", + }, + { + name: "helm chart with missing version", + target: &chart.Chart{ + Metadata: &chart.Metadata{ + APIVersion: "v2", + Name: "sample-chart", + Version: "", + }, + }, + wantErr: true, + errMsg: "chart version is required", + }, + { + name: "helm chart with missing metadata", + target: &chart.Chart{ + Metadata: nil, + }, + wantErr: true, + errMsg: "chart metadata is missing", + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + err := IsValidChart(tc.target) + if tc.wantErr && assert.Error(t, err, "checking valid chart") { + assert.EqualError(t, err, tc.errMsg, "validating chart") + } + }) + } +} + +func TestIsBundleSourceChart(t *testing.T) { + testdataCharts := filepath.Join("../../../../", "testdata", "charts") + tt := []struct { + name string + path string + meta *chart.Metadata + want bool + wantErr bool + errMsg string + }{ + { + name: "complete helm chart with nil *chart.Metadata", + path: filepath.Join(testdataCharts, "sample-chart-0.1.0.tgz"), + meta: nil, + want: true, + wantErr: false, + }, + { + name: "complete helm chart", + path: filepath.Join(testdataCharts, "sample-chart-0.1.0.tgz"), + meta: &chart.Metadata{}, + want: true, + wantErr: false, + }, + { + name: "helm chart without templates", + path: filepath.Join(testdataCharts, "broken-chart-0.1.0.tgz"), + meta: nil, + want: false, + wantErr: true, + errMsg: "templates directory not found", + }, + { + name: "helm chart without a Chart.yaml", + path: filepath.Join(testdataCharts, "missing-meta-0.1.0.tgz"), + meta: nil, + want: false, + wantErr: true, + errMsg: "the Chart.yaml file was not found", + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + chartFS, _ := createTempFS(t, tc.path) + got, err := IsBundleSourceChart(chartFS, tc.meta) + assert.Equal(t, tc.want, got, "validata helm chart") + if tc.wantErr { + assert.EqualError(t, err, tc.errMsg, "chart validation error") + } + }) + } +} + +func createTempFS(t *testing.T, filename string) (fs.FS, error) { + tmpDir, _ := os.MkdirTemp(t.TempDir(), "bundlefs-") + + if filename == "" { + return os.DirFS(tmpDir), nil + } + + f, err := os.Open(filename) + if err != nil { + return os.DirFS(tmpDir), err + } + defer f.Close() + + dest, err := os.Create(filepath.Join(tmpDir, filepath.Base(filename))) + if err != nil { + return nil, err + } + defer dest.Close() + + if _, err := io.Copy(dest, f); err != nil { + return nil, err + } + + return os.DirFS(tmpDir), nil +} + +func Test_loadChartFS(t *testing.T) { + testdataCharts := filepath.Join("../../../../", "testdata", "charts") + type args struct { + filename string + filepath string + } + type want struct { + name string + version string + errMsg string + } + tests := []struct { + name string + args args + want want + expect func(*chart.Chart, want, error) + }{ + { + name: "empty filename is provided", + args: args{ + filename: "", + filepath: "", + }, + want: want{ + name: "", + errMsg: "chart file name was not provided", + }, + expect: func(chart *chart.Chart, want want, err error) { + require.EqualError(t, err, want.errMsg) + assert.Nil(t, chart, "no chart would be returned") + }, + }, + { + name: "load sample chart", + args: args{ + filename: "sample-chart-0.1.0.tgz", + filepath: filepath.Join(testdataCharts, "sample-chart-0.1.0.tgz"), + }, + want: want{ + name: "sample-chart", + version: "0.1.0", + }, + expect: func(chart *chart.Chart, want want, err error) { + require.NoError(t, err, "chart should load successfully") + assert.Equal(t, want.name, chart.Metadata.Name, "verify chart name") + assert.Equal(t, want.version, chart.Metadata.Version, "verify chart version") + }, + }, + { + name: "load nonexistent chart", + args: args{ + filename: "nonexistent-chart-0.1.0.tgz", + filepath: filepath.Join(testdataCharts, "nonexistent-chart-0.1.0.tgz"), + }, + want: want{ + name: "nonexistent-chart", + version: "0.1.0", + }, + expect: func(chart *chart.Chart, want want, err error) { + assert.Nil(t, chart, "chart does not exist on filesystem") + require.Error(t, err, "reading chart nonexistent-chart-0.1.0.tgz; open nonexistent-chart-0.1.0.tgz: no such file or directory") + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + chartFS, _ := createTempFS(t, tc.args.filepath) + + got, err := loadChartFS(chartFS, tc.args.filename) + assert.NotNil(t, tc.expect, "validation function") + tc.expect(got, tc.want, err) + }) + } +} + +func TestLoadChartFSWithOptions(t *testing.T) { + testdataCharts := filepath.Join("../../../../", "testdata", "charts") + type args struct { + filename string + fileSource string + } + type want struct { + name string + version string + errMsg string + } + tests := []struct { + name string + args args + want want + expect func(*chart.Chart, want, error) + }{ + { + name: "empty filename is provided", + args: args{ + filename: "", + fileSource: "", + }, + want: want{ + errMsg: "chart file name was not provided", + }, + expect: func(chart *chart.Chart, want want, err error) { + require.Error(t, err, want.errMsg) + }, + }, + { + name: "load sample chart", + args: args{ + filename: "sample-chart-0.1.0.tgz", + fileSource: filepath.Join(testdataCharts, "sample-chart-0.1.0.tgz"), + }, + want: want{ + name: "sample-chart", + version: "0.1.0", + }, + expect: func(chart *chart.Chart, want want, err error) { + require.NoError(t, err) + assert.Equal(t, want.name, chart.Metadata.Name, "chart name") + assert.Equal(t, want.version, chart.Metadata.Version, "chart version") + }, + }, + { + name: "load nonexistent chart", + args: args{ + filename: "nonexistent-chart-0.1.0.tgz", + fileSource: filepath.Join(testdataCharts, "nonexistent-chart-0.1.0.tgz"), + }, + want: want{ + errMsg: "reading chart nonexistent-chart-0.1.0.tgz; open nonexistent-chart-0.1.0.tgz: no such file or directory", + }, + expect: func(chart *chart.Chart, want want, err error) { + require.Error(t, err, want.errMsg) + assert.Nil(t, chart) + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + chartFS, _ := createTempFS(t, tc.args.fileSource) + got, err := LoadChartFSWithOptions(chartFS, tc.args.filename, WithInstallNamespace("metrics-server-system")) + require.NotNil(t, tc.expect) + tc.expect(got, tc.want, err) + }) + } +} + +func Test_enrichChart(t *testing.T) { + type args struct { + chart *chart.Chart + options []ChartOption + } + tests := []struct { + name string + args args + want *chart.Chart + wantErr bool + }{ + { + name: "enrich empty chart object", + args: args{ + chart: nil, + options: []ChartOption{ + WithInstallNamespace("test-namespace-system"), + }, + }, + wantErr: true, + want: nil, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := enrichChart(tc.args.chart, tc.args.options...) + if (err != nil) != tc.wantErr { + t.Errorf("enrichChart() error = %v, wantErr %v", err, tc.wantErr) + return + } + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("enrichChart() = %v, want %v", got, tc.want) + } + }) + } +} + +func setupChartRegistry(t *testing.T, chartPath string) (reference.NamedTagged, reference.Canonical, func()) { + server := httptest.NewServer(goregistry.New()) + serverURL, err := url.Parse(server.URL) + require.NoError(t, err) + + clientOpts := []registry.ClientOption{ + registry.ClientOptDebug(true), + registry.ClientOptEnableCache(true), + } + client, err := registry.NewClient(clientOpts...) + require.NoError(t, err) + + chart, err := os.ReadFile(chartPath) + require.NoError(t, err) + + testCreationTime := "1977-09-02T22:04:05Z" + ref := fmt.Sprintf("%s/testrepo/sample-chart:%s", serverURL.Host, "0.1.0") + result, err := client.Push(chart, ref, registry.PushOptCreationTime(testCreationTime)) + require.NoError(t, err) + + imageTagRef, err := newReference(serverURL.Host, "testrepo/sample-chart", "0.1.0") + require.NoError(t, err) + + imageDigestRef, err := reference.WithDigest( + reference.TrimNamed(imageTagRef), + digest.Digest(result.Manifest.Digest), + ) + require.NoError(t, err) + + return imageTagRef, imageDigestRef, func() { + server.Close() + } +} diff --git a/internal/shared/util/image/pull.go b/internal/shared/util/image/pull.go index cbef0dcd7..3d8ac62ed 100644 --- a/internal/shared/util/image/pull.go +++ b/internal/shared/util/image/pull.go @@ -224,6 +224,11 @@ func (p *ContainersImagePuller) applyImage(ctx context.Context, ownerID string, } }() + if hasChart(img) { + return pullChart(ctx, ownerID, srcRef, canonicalRef, imgSrc, srcImgRef, cache) + } + + // Helm charts would error when getting OCI config ociImg, err := img.OCIConfig(ctx) if err != nil { return nil, time.Time{}, err @@ -231,7 +236,7 @@ func (p *ContainersImagePuller) applyImage(ctx context.Context, ownerID string, layerIter := iter.Seq[LayerData](func(yield func(LayerData) bool) { for i, layerInfo := range img.LayerInfos() { - ld := LayerData{Index: i} + ld := LayerData{Index: i, MediaType: layerInfo.MediaType} layerReader, _, err := imgSrc.GetBlob(ctx, layerInfo, none.NoCache) if err != nil { ld.Err = fmt.Errorf("error getting layer blob reader: %w", err) diff --git a/testdata/charts/broken-chart-0.1.0.tgz b/testdata/charts/broken-chart-0.1.0.tgz new file mode 100644 index 000000000..1f229c31e Binary files /dev/null and b/testdata/charts/broken-chart-0.1.0.tgz differ diff --git a/testdata/charts/missing-meta-0.1.0.tgz b/testdata/charts/missing-meta-0.1.0.tgz new file mode 100644 index 000000000..d62c542a2 Binary files /dev/null and b/testdata/charts/missing-meta-0.1.0.tgz differ diff --git a/testdata/charts/sample-chart-0.1.0.tgz b/testdata/charts/sample-chart-0.1.0.tgz new file mode 100644 index 000000000..efcfb4e19 Binary files /dev/null and b/testdata/charts/sample-chart-0.1.0.tgz differ