Skip to content

✨ Add support for deploying OCI helm charts in OLM v1 #1971

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions internal/operator-controller/applier/helm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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)
}

Expand Down
9 changes: 9 additions & 0 deletions internal/operator-controller/features/features.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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()
Expand Down
43 changes: 38 additions & 5 deletions internal/shared/util/image/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,19 @@ 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"
fsutil "github.com/operator-framework/operator-controller/internal/shared/util/fs"
)

type LayerData struct {
Reader io.Reader
Index int
Err error
MediaType string
Reader io.Reader
Index int
Err error
}

type Cache interface {
Expand Down Expand Up @@ -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)
}
Expand All @@ -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))
}
Expand Down
18 changes: 18 additions & 0 deletions internal/shared/util/image/cache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package image

import (
"archive/tar"
"bytes"
"context"
"errors"
"io"
Expand All @@ -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"
)
Expand Down Expand Up @@ -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,
Expand Down
222 changes: 222 additions & 0 deletions internal/shared/util/image/helm.go
Original file line number Diff line number Diff line change
@@ -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))
}
Loading
Loading