Skip to content

Commit 5aa38f7

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 * extended the Cache interface to allow storing of Helm charts * inspect chart archive contents Signed-off-by: Edmund Ochieng <ochienged@gmail.com>
1 parent 71108b2 commit 5aa38f7

File tree

10 files changed

+417
-0
lines changed

10 files changed

+417
-0
lines changed

internal/operator-controller/applier/helm.go

Lines changed: 11 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,15 @@ 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, meta, imageutil.WithInstallNamespace(ext.Spec.Namespace),
219+
)
220+
}
221+
}
222+
212223
return h.BundleToHelmChartConverter.ToHelmChart(source.FromFS(bundleFS), ext.Spec.Namespace, watchNamespace)
213224
}
214225

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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ type LayerData struct {
2929
}
3030

3131
type Cache interface {
32+
ExtendCache
3233
Fetch(context.Context, string, reference.Canonical) (fs.FS, time.Time, error)
3334
Store(context.Context, string, reference.Named, reference.Canonical, ocispecv1.Image, iter.Seq[LayerData]) (fs.FS, time.Time, error)
3435
Delete(context.Context, string) error

internal/shared/util/image/helm.go

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
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+
"os"
14+
"path/filepath"
15+
"regexp"
16+
"slices"
17+
"strings"
18+
"time"
19+
20+
"github.com/containers/image/v5/docker/reference"
21+
"github.com/containers/image/v5/types"
22+
"github.com/opencontainers/go-digest"
23+
specsv1 "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+
fsutil "github.com/operator-framework/operator-controller/internal/shared/util/fs"
30+
)
31+
32+
func hasChart(imgCloser types.ImageCloser) bool {
33+
config := imgCloser.ConfigInfo()
34+
return config.MediaType == registry.ConfigMediaType
35+
}
36+
37+
type ExtendCache interface {
38+
StoreChart(string, string, reference.Canonical, io.Reader) (fs.FS, time.Time, error)
39+
}
40+
41+
func (a *diskCache) StoreChart(ownerID, filename string, canonicalRef reference.Canonical, src io.Reader) (fs.FS, time.Time, error) {
42+
dest := a.unpackPath(ownerID, canonicalRef.Digest())
43+
44+
if err := fsutil.EnsureEmptyDirectory(dest, 0700); err != nil {
45+
return nil, time.Time{}, fmt.Errorf("error ensuring empty charts directory: %w", err)
46+
}
47+
48+
// Destination file
49+
chart, err := os.Create(filepath.Join(dest, filename))
50+
if err != nil {
51+
return nil, time.Time{}, fmt.Errorf("creating chart file; %w", err)
52+
}
53+
defer chart.Close()
54+
55+
_, err = io.Copy(chart, src)
56+
if err != nil {
57+
return nil, time.Time{}, fmt.Errorf("copying chart to %s; %w", filename, err)
58+
}
59+
60+
modTime, err := fsutil.GetDirectoryModTime(dest)
61+
if err != nil {
62+
return nil, time.Time{}, fmt.Errorf("error getting mod time of unpack directory: %w", err)
63+
}
64+
return os.DirFS(filepath.Dir(dest)), modTime, nil
65+
}
66+
67+
func pullChart(ctx context.Context, ownerID string, img types.ImageSource, canonicalRef reference.Canonical, cache Cache, imgRef types.ImageReference) (fs.FS, time.Time, error) {
68+
raw, _, err := img.GetManifest(ctx, nil)
69+
if err != nil {
70+
return nil, time.Time{}, fmt.Errorf("get OCI helm chart manifest; %w", err)
71+
}
72+
73+
chartManifest := specsv1.Manifest{}
74+
if err := json.Unmarshal(raw, &chartManifest); err != nil {
75+
return nil, time.Time{}, fmt.Errorf("unmarshaling chart manifest; %w", err)
76+
}
77+
78+
if len(chartManifest.Layers) == 0 {
79+
return nil, time.Time{}, fmt.Errorf("manifest has no layers; expected at least one chart layer")
80+
}
81+
82+
var chartDataLayerDigest digest.Digest
83+
var chartDataLayerFound bool
84+
for i, layer := range chartManifest.Layers {
85+
if layer.MediaType == registry.ChartLayerMediaType {
86+
chartDataLayerDigest = chartManifest.Layers[i].Digest
87+
chartDataLayerFound = true
88+
break
89+
}
90+
}
91+
92+
if !chartDataLayerFound {
93+
return nil, time.Time{}, fmt.Errorf(
94+
"no layer with media type %q found in manifest (total layers: %d)",
95+
registry.ChartLayerMediaType,
96+
len(chartManifest.Layers),
97+
)
98+
}
99+
100+
// Source file
101+
tarball, err := os.Open(filepath.Join(
102+
imgRef.PolicyConfigurationIdentity(), "blobs",
103+
"sha256", chartDataLayerDigest.Encoded()),
104+
)
105+
if err != nil {
106+
return nil, time.Time{}, fmt.Errorf("opening chart data; %w", err)
107+
}
108+
defer tarball.Close()
109+
110+
filename := fmt.Sprintf("%s-%s.tgz",
111+
chartManifest.Annotations["org.opencontainers.image.title"],
112+
chartManifest.Annotations["org.opencontainers.image.version"],
113+
)
114+
return cache.StoreChart(ownerID, filename, canonicalRef, tarball)
115+
}
116+
117+
func IsValidChart(chart *chart.Chart) error {
118+
if chart.Metadata == nil {
119+
return errors.New("chart metadata is missing")
120+
}
121+
if chart.Metadata.Name == "" {
122+
return errors.New("chart name is required")
123+
}
124+
if chart.Metadata.Version == "" {
125+
return errors.New("chart version is required")
126+
}
127+
return chart.Metadata.Validate()
128+
}
129+
130+
type chartInspectionResult struct {
131+
// templatesExist is set to true if the templates
132+
// directory exists in the chart archive
133+
templatesExist bool
134+
// chartfileExists is set to true if the Chart.yaml
135+
// file exists in the chart archive
136+
chartfileExists bool
137+
}
138+
139+
func inspectChart(data []byte, metadata *chart.Metadata) (chartInspectionResult, error) {
140+
gzReader, err := gzip.NewReader(bytes.NewReader(data))
141+
if err != nil {
142+
return chartInspectionResult{}, err
143+
}
144+
defer gzReader.Close()
145+
146+
report := chartInspectionResult{}
147+
tarReader := tar.NewReader(gzReader)
148+
for {
149+
header, err := tarReader.Next()
150+
if err == io.EOF {
151+
if !report.chartfileExists && !report.templatesExist {
152+
return report, errors.New("neither Chart.yaml nor templates directory were found")
153+
}
154+
155+
if !report.chartfileExists {
156+
return report, errors.New("the Chart.yaml file was not found")
157+
}
158+
159+
if !report.templatesExist {
160+
return report, errors.New("templates directory not found")
161+
}
162+
163+
return report, nil
164+
}
165+
166+
if strings.HasSuffix(header.Name, filepath.Join("templates", filepath.Base(header.Name))) {
167+
report.templatesExist = true
168+
}
169+
170+
if filepath.Base(header.Name) == "Chart.yaml" {
171+
report.chartfileExists = true
172+
if err := loadMetadataArchive(tarReader, metadata); err != nil {
173+
return report, err
174+
}
175+
}
176+
}
177+
}
178+
179+
func loadMetadataArchive(r io.Reader, metadata *chart.Metadata) error {
180+
if metadata == nil {
181+
return nil
182+
}
183+
184+
content, err := io.ReadAll(r)
185+
if err != nil {
186+
return fmt.Errorf("reading Chart.yaml; %w", err)
187+
}
188+
189+
if err := yaml.Unmarshal(content, metadata); err != nil {
190+
return fmt.Errorf("unmarshaling Chart.yaml; %w", err)
191+
}
192+
193+
return nil
194+
}
195+
196+
func IsBundleSourceChart(bundleFS fs.FS, metadata *chart.Metadata) (bool, error) {
197+
var chartPath string
198+
files, _ := fs.ReadDir(bundleFS, ".")
199+
for _, file := range files {
200+
if slices.Contains([]string{".tar.gz", ".tgz"}, filepath.Ext(file.Name())) {
201+
chartPath = file.Name()
202+
break
203+
}
204+
}
205+
206+
chartData, err := fs.ReadFile(bundleFS, chartPath)
207+
if err != nil {
208+
return false, err
209+
}
210+
211+
result, err := inspectChart(chartData, metadata)
212+
if err != nil {
213+
return false, err
214+
}
215+
216+
return (result.templatesExist && result.chartfileExists), nil
217+
}
218+
219+
type ChartOption func(*chart.Chart)
220+
221+
func WithInstallNamespace(namespace string) ChartOption {
222+
re := regexp.MustCompile(`{{\W+\.Release\.Namespace\W+}}`)
223+
224+
return func(chrt *chart.Chart) {
225+
for i, template := range chrt.Templates {
226+
chrt.Templates[i].Data = re.ReplaceAll(template.Data, []byte(namespace))
227+
}
228+
}
229+
}
230+
231+
func LoadChartFSWithOptions(bundleFS fs.FS, meta *chart.Metadata, options ...ChartOption) (*chart.Chart, error) {
232+
ch, err := loadChartFS(bundleFS, fmt.Sprintf("%s-%s.tgz", meta.Name, meta.Version))
233+
if err != nil {
234+
return nil, err
235+
}
236+
237+
return enrichChart(ch, options...)
238+
}
239+
240+
func enrichChart(chart *chart.Chart, options ...ChartOption) (*chart.Chart, error) {
241+
if chart != nil {
242+
for _, f := range options {
243+
f(chart)
244+
}
245+
return chart, nil
246+
}
247+
return nil, fmt.Errorf("chart can not be nil")
248+
}
249+
250+
func loadChartFS(bundleFS fs.FS, filename string) (*chart.Chart, error) {
251+
if filename == "" {
252+
return nil, fmt.Errorf("chart file name was not provided")
253+
}
254+
255+
tarball, err := fs.ReadFile(bundleFS, filename)
256+
if err != nil {
257+
return nil, fmt.Errorf("reading chart %s; %+v", filename, err)
258+
}
259+
return loader.LoadArchive(bytes.NewBuffer(tarball))
260+
}

0 commit comments

Comments
 (0)