diff --git a/internal/operator-controller/applier/helm.go b/internal/operator-controller/applier/helm.go index 6b3af506f..ecfb3fdc2 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..34a665af6 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" ) @@ -144,6 +146,67 @@ func TestDiskCacheFetch(t *testing.T) { } } +func TestDiskCacheStore_HelmChart(t *testing.T) { + const myOwner = "myOwner" + myCanonicalRef := mustParseCanonical(t, "my.registry.io/ns/repo@sha256:5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03") + myTaggedRef, err := reference.WithTag(reference.TrimNamed(myCanonicalRef), "test-tag") + require.NoError(t, err) + + testCases := []struct { + name string + ownerID string + srcRef reference.Named + canonicalRef reference.Canonical + imgConfig ocispecv1.Image + layers iter.Seq[LayerData] + filterFunc func(context.Context, reference.Named, ocispecv1.Image) (archive.Filter, error) + setup func(*testing.T, *diskCache) + expect func(*testing.T, *diskCache, fs.FS, time.Time, error) + }{ + { + name: "returns no error if layer read contains helm chart", + ownerID: myOwner, + srcRef: myTaggedRef, + canonicalRef: myCanonicalRef, + layers: func() iter.Seq[LayerData] { + testChart := mockHelmChartTgz(t, + []fileContent{ + { + name: "testchart/Chart.yaml", + content: []byte("apiVersion: v2\nname: testchart\nversion: 0.1.0"), + }, + { + name: "testchart/templates/deployment.yaml", + content: []byte("kind: Deployment\napiVersion: apps/v1"), + }, + }, + ) + return func(yield func(LayerData) bool) { + yield(LayerData{Reader: bytes.NewBuffer(testChart), MediaType: registry.ChartLayerMediaType}) + } + }(), + expect: func(t *testing.T, cache *diskCache, fsys fs.FS, modTime time.Time, err error) { + require.NoError(t, err) + }, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + dc := &diskCache{ + basePath: t.TempDir(), + filterFunc: tc.filterFunc, + } + if tc.setup != nil { + tc.setup(t, dc) + } + fsys, modTime, err := dc.Store(context.Background(), tc.ownerID, tc.srcRef, tc.canonicalRef, tc.imgConfig, tc.layers) + require.NotNil(t, tc.expect, "test case must include an expect function") + tc.expect(t, dc, fsys, modTime, err) + require.NoError(t, fsutil.DeleteReadOnlyRecursive(dc.basePath)) + }) + } +} + func TestDiskCacheStore(t *testing.T) { const myOwner = "myOwner" myCanonicalRef := mustParseCanonical(t, "my.registry.io/ns/repo@sha256:5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03") @@ -585,6 +648,7 @@ func TestDiskCacheGarbageCollection(t *testing.T) { } } +//nolint:unparam func mustParseCanonical(t *testing.T, s string) reference.Canonical { n, err := reference.ParseNamed(s) require.NoError(t, err) diff --git a/internal/shared/util/image/helm.go b/internal/shared/util/image/helm.go new file mode 100644 index 000000000..7f2e997f8 --- /dev/null +++ b/internal/shared/util/image/helm.go @@ -0,0 +1,224 @@ +package image + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "iter" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/containers/image/v5/docker/reference" + "github.com/containers/image/v5/pkg/blobinfocache/none" + "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, 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 { + ld.Reader, _, ld.Err = imgSrc.GetBlob(ctx, + types.BlobInfo{ + Annotations: layer.Annotations, + MediaType: layer.MediaType, + Digest: layer.Digest, + Size: layer.Size, + }, + none.NoCache) + } + // 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{}, fmt.Errorf("creating gzip reader: %w", 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 strings.HasSuffix(file.Name(), ".tgz") || + strings.HasSuffix(file.Name(), ".tar.gz") { + 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, fmt.Errorf("reading %s from fs: %w", chartPath, 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..6f1400b5e --- /dev/null +++ b/internal/shared/util/image/helm_test.go @@ -0,0 +1,577 @@ +package image + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "fmt" + "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 := "testchart-0.1.0.tgz" + testChart := mockHelmChartTgz(t, + []fileContent{ + { + name: "testchart/Chart.yaml", + content: []byte("apiVersion: v2\nname: testchart\nversion: 0.1.0"), + }, + { + name: "testchart/templates/deployment.yaml", + content: []byte("kind: Deployment\napiVersion: apps/v1"), + }, + }, + ) + + myTagRef, myCanonicalRef, cleanup := setupChartRegistry(t, testChart) + 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) + + assert.Equal(t, testChart, 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) { + type args struct { + meta *chart.Metadata + files []fileContent + } + type want struct { + value bool + errStr string + } + tt := []struct { + name string + args args + want want + }{ + { + name: "complete helm chart with nil *chart.Metadata", + args: args{ + meta: nil, + files: []fileContent{ + { + name: "testchart/Chart.yaml", + content: []byte("apiVersion: v2\nname: testchart\nversion: 0.1.0"), + }, + { + name: "testchart/templates/deployment.yaml", + content: []byte("kind: Deployment\napiVersion: apps/v1"), + }, + }, + }, + want: want{ + value: true, + }, + }, + { + name: "complete helm chart", + args: args{ + meta: &chart.Metadata{}, + files: []fileContent{ + { + name: "testchart/Chart.yaml", + content: []byte("apiVersion: v2\nname: testchart\nversion: 0.1.0"), + }, + { + name: "testchart/templates/deployment.yaml", + content: []byte("kind: Deployment\napiVersion: apps/v1"), + }, + }, + }, + want: want{ + value: true, + }, + }, + { + name: "helm chart without templates", + args: args{ + meta: nil, + files: []fileContent{ + { + name: "testchart/Chart.yaml", + content: []byte("apiVersion: v2\nname: testchart\nversion: 0.1.0"), + }, + }, + }, + want: want{ + value: false, + errStr: "reading testchart-0.1.0.tgz from fs: templates directory not found", + }, + }, + { + name: "helm chart without a Chart.yaml", + args: args{ + meta: nil, + files: []fileContent{ + { + name: "testchart/templates/deployment.yaml", + content: []byte("kind: Deployment\napiVersion: apps/v1"), + }, + }, + }, + want: want{ + value: false, + errStr: "reading testchart-0.1.0.tgz from fs: the Chart.yaml file was not found", + }, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + chartFS, _ := createTempFS(t, mockHelmChartTgz(t, tc.args.files)) + got, err := IsBundleSourceChart(chartFS, tc.args.meta) + if tc.want.errStr != "" { + require.Error(t, err, "chart validation error") + } + require.Equal(t, tc.want.value, got, "validata helm chart") + }) + } +} + +func Test_loadChartFS(t *testing.T) { + type args struct { + filename string + files []fileContent + } + 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: "", + files: []fileContent{ + { + name: "testchart/Chart.yaml", + content: []byte("apiVersion: v2\nname: testchart\nversion: 0.1.0"), + }, + { + name: "testchart/templates/deployment.yaml", + content: []byte("kind: Deployment\napiVersion: apps/v1"), + }, + }, + }, + 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: "testchart-0.1.0.tgz", + files: []fileContent{ + { + name: "testchart/Chart.yaml", + content: []byte("apiVersion: v2\nname: testchart\nversion: 0.1.0"), + }, + { + name: "testchart/templates/deployment.yaml", + content: []byte("kind: Deployment\napiVersion: apps/v1"), + }, + }, + }, + want: want{ + name: "testchart", + 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", + files: []fileContent{ + { + name: "testchart/Chart.yaml", + content: []byte("apiVersion: v2\nname: testchart\nversion: 0.1.0"), + }, + { + name: "testchart/templates/deployment.yaml", + content: []byte("kind: Deployment\napiVersion: apps/v1"), + }, + }, + }, + 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, mockHelmChartTgz(t, tc.args.files)) + + 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) { + type args struct { + filename string + files []fileContent + } + 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: "", + files: []fileContent{ + { + name: "testchart/Chart.yaml", + content: []byte("apiVersion: v2\nname: testchart\nversion: 0.1.0"), + }, + { + name: "testchart/templates/deployment.yaml", + content: []byte("kind: Deployment\napiVersion: apps/v1"), + }, + }, + }, + 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: "testchart-0.1.0.tgz", + files: []fileContent{ + { + name: "testchart/Chart.yaml", + content: []byte("apiVersion: v2\nname: testchart\nversion: 0.1.0"), + }, + { + name: "testchart/templates/deployment.yaml", + content: []byte("kind: Deployment\napiVersion: apps/v1"), + }, + }, + }, + want: want{ + name: "testchart", + 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", + files: []fileContent{ + { + name: "testchart/Chart.yaml", + content: []byte("apiVersion: v2\nname: testchart\nversion: 0.1.0"), + }, + { + name: "testchart/templates/deployment.yaml", + content: []byte("kind: Deployment\napiVersion: apps/v1"), + }, + }, + }, + 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, mockHelmChartTgz(t, tc.args.files)) + 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, chart []byte) (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) + + testCreationTime := "1977-09-02T22:04:05Z" + ref := fmt.Sprintf("%s/testrepo/testchart:%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/testchart", "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() + } +} + +type fileContent struct { + name string + content []byte +} + +func mockHelmChartTgz(t *testing.T, contents []fileContent) []byte { + require.NotEmpty(t, contents, "chart content required") + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + + // Add files to the chart archive + for _, file := range contents { + require.NoError(t, tw.WriteHeader(&tar.Header{ + Name: file.name, + Mode: 0600, + Size: int64(len(file.content)), + })) + _, _ = tw.Write(file.content) + } + + require.NoError(t, tw.Close()) + + var gzBuf bytes.Buffer + gz := gzip.NewWriter(&gzBuf) + _, err := gz.Write(buf.Bytes()) + require.NoError(t, err) + require.NoError(t, gz.Close()) + + return gzBuf.Bytes() +} + +func createTempFS(t *testing.T, data []byte) (fs.FS, error) { + require.NotEmpty(t, data, "chart data") + tmpDir, _ := os.MkdirTemp(t.TempDir(), "bundlefs-") + if len(data) == 0 { + return os.DirFS(tmpDir), nil + } + + dest, err := os.Create(filepath.Join(tmpDir, "testchart-0.1.0.tgz")) + if err != nil { + return nil, err + } + defer dest.Close() + + if _, err := dest.Write(data); err != nil { + return nil, err + } + + return os.DirFS(tmpDir), nil +} diff --git a/internal/shared/util/image/pull.go b/internal/shared/util/image/pull.go index cbef0dcd7..37afb124a 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, 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)