From 39948a0295bd2b6e905bcd79c6fa64a5146ad75c Mon Sep 17 00:00:00 2001 From: Edmund Ochieng Date: Fri, 2 May 2025 09:19:37 -0500 Subject: [PATCH] Add support for deploying OCI helm charts in OLM v1 * added support for deploying OCI helm charts which sits behind the HelmChartSupport feature gate * extended the Cache interface to allow storing of Helm charts Signed-off-by: Edmund Ochieng --- internal/operator-controller/applier/helm.go | 78 ++++++++++++++++ .../operator-controller/features/features.go | 9 ++ internal/shared/util/image/cache.go | 1 + internal/shared/util/image/helm.go | 90 +++++++++++++++++++ internal/shared/util/image/mocks.go | 5 ++ internal/shared/util/image/pull.go | 5 ++ 6 files changed, 188 insertions(+) create mode 100644 internal/shared/util/image/helm.go diff --git a/internal/operator-controller/applier/helm.go b/internal/operator-controller/applier/helm.go index cc47cc5a3..3872731e5 100644 --- a/internal/operator-controller/applier/helm.go +++ b/internal/operator-controller/applier/helm.go @@ -7,11 +7,14 @@ import ( "fmt" "io" "io/fs" + "path/filepath" + "regexp" "slices" "strings" "helm.sh/helm/v3/pkg/action" "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chart/loader" "helm.sh/helm/v3/pkg/chartutil" "helm.sh/helm/v3/pkg/postrender" "helm.sh/helm/v3/pkg/release" @@ -26,6 +29,7 @@ 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" @@ -209,9 +213,83 @@ func (h *Helm) buildHelmChart(bundleFS fs.FS, ext *ocv1.ClusterExtension) (*char if err != nil { return nil, err } + if features.OperatorControllerFeatureGate.Enabled(features.HelmChartSupport) { + chart := new(chart.Chart) + if IsBundleSourceHelmChart(bundleFS, chart) { + return enrichChart(chart, WithInstallNamespace(ext.Spec.Namespace)) + } + } + return h.BundleToHelmChartConverter.ToHelmChart(source.FromFS(bundleFS), ext.Spec.Namespace, watchNamespace) } +func IsBundleSourceHelmChart(bundleFS fs.FS, chart *chart.Chart) bool { + var filename string + if err := fs.WalkDir(bundleFS, ".", + func(path string, f fs.DirEntry, err error) error { + if err != nil { + return err + } + + if filepath.Ext(f.Name()) == ".tgz" && + !f.IsDir() { + filename = path + return fs.SkipAll + } + + return nil + }, + ); err != nil && + !errors.Is(err, fs.SkipAll) { + return false + } + + ch, err := readChartFS(bundleFS, filename) + if err != nil { + return false + } + *chart = *ch + + return chart.Metadata != nil && + chart.Metadata.Name != "" && + chart.Metadata.Version != "" && + len(chart.Templates) > 0 +} + +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 enrichChart(chart *chart.Chart, options ...ChartOption) (*chart.Chart, error) { + if chart != nil { + for _, f := range options { + f(chart) + } + return chart, nil + } + return nil, fmt.Errorf("chart can not be nil") +} + +func readChartFS(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)) +} + func (h *Helm) renderClientOnlyRelease(ctx context.Context, ext *ocv1.ClusterExtension, chrt *chart.Chart, values chartutil.Values, post postrender.PostRenderer) (*release.Release, error) { // We need to get a separate action client because our work below // permanently modifies the underlying action.Configuration for ClientOnly mode. 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..9f81d75ac 100644 --- a/internal/shared/util/image/cache.go +++ b/internal/shared/util/image/cache.go @@ -29,6 +29,7 @@ type LayerData struct { } type Cache interface { + ExtendCache Fetch(context.Context, string, reference.Canonical) (fs.FS, time.Time, error) Store(context.Context, string, reference.Named, reference.Canonical, ocispecv1.Image, iter.Seq[LayerData]) (fs.FS, time.Time, error) Delete(context.Context, string) error diff --git a/internal/shared/util/image/helm.go b/internal/shared/util/image/helm.go new file mode 100644 index 000000000..da2596e40 --- /dev/null +++ b/internal/shared/util/image/helm.go @@ -0,0 +1,90 @@ +package image + +import ( + "context" + "encoding/json" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "time" + + "github.com/containers/image/v5/docker/reference" + "github.com/containers/image/v5/types" + "github.com/opencontainers/go-digest" + specsv1 "github.com/opencontainers/image-spec/specs-go/v1" + "helm.sh/helm/v3/pkg/registry" + + fsutil "github.com/operator-framework/operator-controller/internal/shared/util/fs" +) + +func hasChart(imgCloser types.ImageCloser) bool { + config := imgCloser.ConfigInfo() + return config.MediaType == registry.ConfigMediaType +} + +type ExtendCache interface { + StoreChart(string, string, reference.Canonical, io.Reader) (fs.FS, time.Time, error) +} + +func (a *diskCache) StoreChart(ownerID, filename string, canonicalRef reference.Canonical, src io.Reader) (fs.FS, time.Time, error) { + dest := a.unpackPath(ownerID, canonicalRef.Digest()) + + if err := fsutil.EnsureEmptyDirectory(dest, 0700); err != nil { + return nil, time.Time{}, fmt.Errorf("error ensuring empty charts directory: %w", err) + } + + // Destination file + chart, err := os.Create(filepath.Join(dest, filename)) + if err != nil { + return nil, time.Time{}, fmt.Errorf("creating chart file; %w", err) + } + defer chart.Close() + + _, err = io.Copy(chart, src) + if err != nil { + return nil, time.Time{}, fmt.Errorf("copying chart to %s; %w", filename, err) + } + + modTime, err := fsutil.GetDirectoryModTime(dest) + if err != nil { + return nil, time.Time{}, fmt.Errorf("error getting mod time of unpack directory: %w", err) + } + return os.DirFS(filepath.Dir(dest)), modTime, nil +} + +func pullChart(ctx context.Context, ownerID string, img types.ImageSource, canonicalRef reference.Canonical, cache Cache, imgRef types.ImageReference) (fs.FS, time.Time, error) { + raw, _, err := img.GetManifest(ctx, nil) + if err != nil { + return nil, time.Time{}, fmt.Errorf("get OCI helm chart manifest; %w", err) + } + + chartManifest := specsv1.Manifest{} + if err := json.Unmarshal(raw, &chartManifest); err != nil { + return nil, time.Time{}, fmt.Errorf("unmarshaling chart manifest; %w", err) + } + + var chartDataLayerDigest digest.Digest + if len(chartManifest.Layers) == 1 && + (chartManifest.Layers[0].MediaType == registry.ChartLayerMediaType) { + chartDataLayerDigest = chartManifest.Layers[0].Digest + } + + filename := fmt.Sprintf("%s-%s.tgz", + chartManifest.Annotations["org.opencontainers.image.title"], + chartManifest.Annotations["org.opencontainers.image.version"], + ) + + // Source file + tarball, err := os.Open(filepath.Join( + imgRef.PolicyConfigurationIdentity(), "blobs", + "sha256", chartDataLayerDigest.Encoded()), + ) + if err != nil { + return nil, time.Time{}, fmt.Errorf("opening chart data; %w", err) + } + defer tarball.Close() + + return cache.StoreChart(ownerID, filename, canonicalRef, tarball) +} diff --git a/internal/shared/util/image/mocks.go b/internal/shared/util/image/mocks.go index 903983181..fe511ad09 100644 --- a/internal/shared/util/image/mocks.go +++ b/internal/shared/util/image/mocks.go @@ -2,6 +2,7 @@ package image import ( "context" + "io" "io/fs" "iter" "time" @@ -44,6 +45,10 @@ type MockCache struct { GarbageCollectError error } +func (m MockCache) StoreChart(_ string, _ string, _ reference.Canonical, _ io.Reader) (fs.FS, time.Time, error) { + return m.StoreFS, m.StoreModTime, m.StoreError +} + func (m MockCache) Fetch(_ context.Context, _ string, _ reference.Canonical) (fs.FS, time.Time, error) { return m.FetchFS, m.FetchModTime, m.FetchError } diff --git a/internal/shared/util/image/pull.go b/internal/shared/util/image/pull.go index cbef0dcd7..3a08b6c90 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, imgSrc, canonicalRef, cache, srcImgRef) + } + + // Helm charts would error when getting OCI config ociImg, err := img.OCIConfig(ctx) if err != nil { return nil, time.Time{}, err