Skip to content

Commit 4b69de7

Browse files
committed
Add support for deploying OCI helm charts in OLM v1
* added support for deploying OCI helm charts which sits behind the HelmChartSupport feature gate * extend the Cache Store() method to allow storing of Helm charts * inspect chart archive contents * added MediaType to the LayerData struct Signed-off-by: Edmund Ochieng <[email protected]>
1 parent 8f81c23 commit 4b69de7

File tree

10 files changed

+754
-6
lines changed

10 files changed

+754
-6
lines changed

internal/operator-controller/applier/helm.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,11 @@ import (
2626

2727
ocv1 "github.com/operator-framework/operator-controller/api/v1"
2828
"github.com/operator-framework/operator-controller/internal/operator-controller/authorization"
29+
"github.com/operator-framework/operator-controller/internal/operator-controller/features"
2930
"github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/bundle/source"
3031
"github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/preflights/crdupgradesafety"
3132
"github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/util"
33+
imageutil "github.com/operator-framework/operator-controller/internal/shared/util/image"
3234
)
3335

3436
const (
@@ -209,6 +211,17 @@ func (h *Helm) buildHelmChart(bundleFS fs.FS, ext *ocv1.ClusterExtension) (*char
209211
if err != nil {
210212
return nil, err
211213
}
214+
if features.OperatorControllerFeatureGate.Enabled(features.HelmChartSupport) {
215+
meta := new(chart.Metadata)
216+
if ok, _ := imageutil.IsBundleSourceChart(bundleFS, meta); ok {
217+
return imageutil.LoadChartFSWithOptions(
218+
bundleFS,
219+
fmt.Sprintf("%s-%s.tgz", meta.Name, meta.Version),
220+
imageutil.WithInstallNamespace(ext.Spec.Namespace),
221+
)
222+
}
223+
}
224+
212225
return h.BundleToHelmChartConverter.ToHelmChart(source.FromFS(bundleFS), ext.Spec.Namespace, watchNamespace)
213226
}
214227

internal/operator-controller/features/features.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const (
1616
SyntheticPermissions featuregate.Feature = "SyntheticPermissions"
1717
WebhookProviderCertManager featuregate.Feature = "WebhookProviderCertManager"
1818
WebhookProviderOpenshiftServiceCA featuregate.Feature = "WebhookProviderOpenshiftServiceCA"
19+
HelmChartSupport featuregate.Feature = "HelmChartSupport"
1920
)
2021

2122
var operatorControllerFeatureGates = map[featuregate.Feature]featuregate.FeatureSpec{
@@ -63,6 +64,14 @@ var operatorControllerFeatureGates = map[featuregate.Feature]featuregate.Feature
6364
PreRelease: featuregate.Alpha,
6465
LockToDefault: false,
6566
},
67+
68+
// HelmChartSupport enables support for installing,
69+
// updating and uninstalling Helm Charts via Cluster Extensions.
70+
HelmChartSupport: {
71+
Default: false,
72+
PreRelease: featuregate.Alpha,
73+
LockToDefault: false,
74+
},
6675
}
6776

6877
var OperatorControllerFeatureGate featuregate.MutableFeatureGate = featuregate.NewFeatureGate()

internal/shared/util/image/cache.go

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,19 @@ import (
1616
"github.com/containers/image/v5/docker/reference"
1717
"github.com/opencontainers/go-digest"
1818
ocispecv1 "github.com/opencontainers/image-spec/specs-go/v1"
19+
"helm.sh/helm/v3/pkg/chart"
20+
"helm.sh/helm/v3/pkg/registry"
1921
"sigs.k8s.io/controller-runtime/pkg/log"
2022

2123
errorutil "github.com/operator-framework/operator-controller/internal/shared/util/error"
2224
fsutil "github.com/operator-framework/operator-controller/internal/shared/util/fs"
2325
)
2426

2527
type LayerData struct {
26-
Reader io.Reader
27-
Index int
28-
Err error
28+
MediaType string
29+
Reader io.Reader
30+
Index int
31+
Err error
2932
}
3033

3134
type Cache interface {
@@ -128,8 +131,15 @@ func (a *diskCache) Store(ctx context.Context, ownerID string, srcRef reference.
128131
if layer.Err != nil {
129132
return fmt.Errorf("error reading layer[%d]: %w", layer.Index, layer.Err)
130133
}
131-
if _, err := archive.Apply(ctx, dest, layer.Reader, applyOpts...); err != nil {
132-
return fmt.Errorf("error applying layer[%d]: %w", layer.Index, err)
134+
switch layer.MediaType {
135+
case registry.ChartLayerMediaType:
136+
if err := storeChartLayer(dest, layer); err != nil {
137+
return err
138+
}
139+
default:
140+
if _, err := archive.Apply(ctx, dest, layer.Reader, applyOpts...); err != nil {
141+
return fmt.Errorf("error applying layer[%d]: %w", layer.Index, err)
142+
}
133143
}
134144
l.Info("applied layer", "layer", layer.Index)
135145
}
@@ -147,6 +157,29 @@ func (a *diskCache) Store(ctx context.Context, ownerID string, srcRef reference.
147157
return os.DirFS(dest), modTime, nil
148158
}
149159

160+
func storeChartLayer(path string, layer LayerData) error {
161+
data, err := io.ReadAll(layer.Reader)
162+
if err != nil {
163+
return fmt.Errorf("error reading layer[%d]: %w", layer.Index, layer.Err)
164+
}
165+
meta := new(chart.Metadata)
166+
_, err = inspectChart(data, meta)
167+
if err != nil {
168+
return fmt.Errorf("inspecting chart layer: %w", err)
169+
}
170+
filename := filepath.Join(path,
171+
fmt.Sprintf("%s-%s.tgz", meta.Name, meta.Version),
172+
)
173+
chart, err := os.Create(filename)
174+
if err != nil {
175+
return fmt.Errorf("inspecting chart layer: %w", err)
176+
}
177+
defer chart.Close()
178+
179+
_, err = chart.Write(data)
180+
return err
181+
}
182+
150183
func (a *diskCache) Delete(_ context.Context, ownerID string) error {
151184
return fsutil.DeleteReadOnlyRecursive(a.ownerIDPath(ownerID))
152185
}

internal/shared/util/image/cache_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package image
22

33
import (
44
"archive/tar"
5+
"bytes"
56
"context"
67
"errors"
78
"io"
@@ -20,6 +21,7 @@ import (
2021
ocispecv1 "github.com/opencontainers/image-spec/specs-go/v1"
2122
"github.com/stretchr/testify/assert"
2223
"github.com/stretchr/testify/require"
24+
"helm.sh/helm/v3/pkg/registry"
2325

2426
fsutil "github.com/operator-framework/operator-controller/internal/shared/util/fs"
2527
)
@@ -211,6 +213,22 @@ func TestDiskCacheStore(t *testing.T) {
211213
assert.ErrorContains(t, err, "error applying layer")
212214
},
213215
},
216+
{
217+
name: "returns no error if layer read contains helm chart",
218+
ownerID: myOwner,
219+
srcRef: myTaggedRef,
220+
canonicalRef: myCanonicalRef,
221+
layers: func() iter.Seq[LayerData] {
222+
sampleChart := filepath.Join("../../../../", "testdata", "charts", "sample-chart-0.1.0.tgz")
223+
data, _ := os.ReadFile(sampleChart)
224+
return func(yield func(LayerData) bool) {
225+
yield(LayerData{Reader: bytes.NewBuffer(data), MediaType: registry.ChartLayerMediaType})
226+
}
227+
}(),
228+
expect: func(t *testing.T, cache *diskCache, fsys fs.FS, modTime time.Time, err error) {
229+
require.NoError(t, err)
230+
},
231+
},
214232
{
215233
name: "no error and an empty FS returned when there are no layers",
216234
ownerID: myOwner,

internal/shared/util/image/helm.go

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
package image
2+
3+
import (
4+
"archive/tar"
5+
"bytes"
6+
"compress/gzip"
7+
"context"
8+
"encoding/json"
9+
"errors"
10+
"fmt"
11+
"io"
12+
"io/fs"
13+
"iter"
14+
"os"
15+
"path/filepath"
16+
"regexp"
17+
"slices"
18+
"strings"
19+
"time"
20+
21+
"github.com/containers/image/v5/docker/reference"
22+
"github.com/containers/image/v5/types"
23+
ocispecv1 "github.com/opencontainers/image-spec/specs-go/v1"
24+
"gopkg.in/yaml.v2"
25+
"helm.sh/helm/v3/pkg/chart"
26+
"helm.sh/helm/v3/pkg/chart/loader"
27+
"helm.sh/helm/v3/pkg/registry"
28+
)
29+
30+
func hasChart(imgCloser types.ImageCloser) bool {
31+
config := imgCloser.ConfigInfo()
32+
return config.MediaType == registry.ConfigMediaType
33+
}
34+
35+
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) {
36+
imgDigest := canonicalRef.Digest()
37+
raw, _, err := imgSrc.GetManifest(ctx, &imgDigest)
38+
if err != nil {
39+
return nil, time.Time{}, fmt.Errorf("get OCI helm chart manifest; %w", err)
40+
}
41+
42+
chartManifest := ocispecv1.Manifest{}
43+
if err := json.Unmarshal(raw, &chartManifest); err != nil {
44+
return nil, time.Time{}, fmt.Errorf("unmarshaling chart manifest; %w", err)
45+
}
46+
47+
if len(chartManifest.Layers) == 0 {
48+
return nil, time.Time{}, fmt.Errorf("manifest has no layers; expected at least one chart layer")
49+
}
50+
51+
layerIter := iter.Seq[LayerData](func(yield func(LayerData) bool) {
52+
for i, layer := range chartManifest.Layers {
53+
ld := LayerData{Index: i, MediaType: layer.MediaType}
54+
if layer.MediaType == registry.ChartLayerMediaType {
55+
var contents []byte
56+
contents, ld.Err = os.ReadFile(filepath.Join(
57+
imgRef.PolicyConfigurationIdentity(), "blobs",
58+
"sha256", chartManifest.Layers[i].Digest.Encoded()),
59+
)
60+
ld.Reader = bytes.NewBuffer(contents)
61+
}
62+
// Ignore the Helm provenance data layer
63+
if layer.MediaType == registry.ProvLayerMediaType {
64+
continue
65+
}
66+
if !yield(ld) {
67+
return
68+
}
69+
}
70+
})
71+
72+
return cache.Store(ctx, ownerID, srcRef, canonicalRef, ocispecv1.Image{}, layerIter)
73+
}
74+
75+
func IsValidChart(chart *chart.Chart) error {
76+
if chart.Metadata == nil {
77+
return errors.New("chart metadata is missing")
78+
}
79+
if chart.Metadata.Name == "" {
80+
return errors.New("chart name is required")
81+
}
82+
if chart.Metadata.Version == "" {
83+
return errors.New("chart version is required")
84+
}
85+
return chart.Metadata.Validate()
86+
}
87+
88+
type chartInspectionResult struct {
89+
// templatesExist is set to true if the templates
90+
// directory exists in the chart archive
91+
templatesExist bool
92+
// chartfileExists is set to true if the Chart.yaml
93+
// file exists in the chart archive
94+
chartfileExists bool
95+
}
96+
97+
func inspectChart(data []byte, metadata *chart.Metadata) (chartInspectionResult, error) {
98+
gzReader, err := gzip.NewReader(bytes.NewReader(data))
99+
if err != nil {
100+
return chartInspectionResult{}, err
101+
}
102+
defer gzReader.Close()
103+
104+
report := chartInspectionResult{}
105+
tarReader := tar.NewReader(gzReader)
106+
for {
107+
header, err := tarReader.Next()
108+
if err == io.EOF {
109+
if !report.chartfileExists && !report.templatesExist {
110+
return report, errors.New("neither Chart.yaml nor templates directory were found")
111+
}
112+
113+
if !report.chartfileExists {
114+
return report, errors.New("the Chart.yaml file was not found")
115+
}
116+
117+
if !report.templatesExist {
118+
return report, errors.New("templates directory not found")
119+
}
120+
121+
return report, nil
122+
}
123+
124+
if strings.HasSuffix(header.Name, filepath.Join("templates", filepath.Base(header.Name))) {
125+
report.templatesExist = true
126+
}
127+
128+
if filepath.Base(header.Name) == "Chart.yaml" {
129+
report.chartfileExists = true
130+
if err := loadMetadataArchive(tarReader, metadata); err != nil {
131+
return report, err
132+
}
133+
}
134+
}
135+
}
136+
137+
func loadMetadataArchive(r io.Reader, metadata *chart.Metadata) error {
138+
if metadata == nil {
139+
return nil
140+
}
141+
142+
content, err := io.ReadAll(r)
143+
if err != nil {
144+
return fmt.Errorf("reading Chart.yaml; %w", err)
145+
}
146+
147+
if err := yaml.Unmarshal(content, metadata); err != nil {
148+
return fmt.Errorf("unmarshaling Chart.yaml; %w", err)
149+
}
150+
151+
return nil
152+
}
153+
154+
func IsBundleSourceChart(bundleFS fs.FS, metadata *chart.Metadata) (bool, error) {
155+
var chartPath string
156+
files, _ := fs.ReadDir(bundleFS, ".")
157+
for _, file := range files {
158+
if slices.Contains([]string{".tar.gz", ".tgz"}, filepath.Ext(file.Name())) {
159+
chartPath = file.Name()
160+
break
161+
}
162+
}
163+
164+
chartData, err := fs.ReadFile(bundleFS, chartPath)
165+
if err != nil {
166+
return false, err
167+
}
168+
169+
result, err := inspectChart(chartData, metadata)
170+
if err != nil {
171+
return false, err
172+
}
173+
174+
return (result.templatesExist && result.chartfileExists), nil
175+
}
176+
177+
type ChartOption func(*chart.Chart)
178+
179+
func WithInstallNamespace(namespace string) ChartOption {
180+
re := regexp.MustCompile(`{{\W+\.Release\.Namespace\W+}}`)
181+
182+
return func(chrt *chart.Chart) {
183+
for i, template := range chrt.Templates {
184+
chrt.Templates[i].Data = re.ReplaceAll(template.Data, []byte(namespace))
185+
}
186+
}
187+
}
188+
189+
func LoadChartFSWithOptions(bundleFS fs.FS, filename string, options ...ChartOption) (*chart.Chart, error) {
190+
ch, err := loadChartFS(bundleFS, filename)
191+
if err != nil {
192+
return nil, err
193+
}
194+
195+
return enrichChart(ch, options...)
196+
}
197+
198+
func enrichChart(chart *chart.Chart, options ...ChartOption) (*chart.Chart, error) {
199+
if chart == nil {
200+
return nil, fmt.Errorf("chart can not be nil")
201+
}
202+
for _, f := range options {
203+
f(chart)
204+
}
205+
return chart, nil
206+
}
207+
208+
var LoadChartFS = loadChartFS
209+
210+
// loadChartFS loads a chart archive from a filesystem of
211+
// type fs.FS with the provided filename
212+
func loadChartFS(bundleFS fs.FS, filename string) (*chart.Chart, error) {
213+
if filename == "" {
214+
return nil, fmt.Errorf("chart file name was not provided")
215+
}
216+
217+
tarball, err := fs.ReadFile(bundleFS, filename)
218+
if err != nil {
219+
return nil, fmt.Errorf("reading chart %s; %+v", filename, err)
220+
}
221+
return loader.LoadArchive(bytes.NewBuffer(tarball))
222+
}

0 commit comments

Comments
 (0)