Skip to content

Commit 742cb8a

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 <ochienged@gmail.com>
1 parent 00b965c commit 742cb8a

File tree

7 files changed

+1127
-6
lines changed

7 files changed

+1127
-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: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package image
22

33
import (
4+
"bytes"
45
"context"
56
"errors"
67
"fmt"
@@ -10,22 +11,28 @@ import (
1011
"os"
1112
"path/filepath"
1213
"slices"
14+
"testing"
1315
"time"
1416

1517
"github.com/containerd/containerd/archive"
1618
"github.com/containers/image/v5/docker/reference"
19+
"github.com/google/renameio/v2"
1720
"github.com/opencontainers/go-digest"
1821
ocispecv1 "github.com/opencontainers/image-spec/specs-go/v1"
22+
"helm.sh/helm/v3/pkg/chart"
23+
"helm.sh/helm/v3/pkg/registry"
1924
"sigs.k8s.io/controller-runtime/pkg/log"
2025

26+
"github.com/operator-framework/operator-controller/internal/operator-controller/features"
2127
errorutil "github.com/operator-framework/operator-controller/internal/shared/util/error"
2228
fsutil "github.com/operator-framework/operator-controller/internal/shared/util/fs"
2329
)
2430

2531
type LayerData struct {
26-
Reader io.Reader
27-
Index int
28-
Err error
32+
MediaType string
33+
Reader io.Reader
34+
Index int
35+
Err error
2936
}
3037

3138
type Cache interface {
@@ -128,8 +135,16 @@ func (a *diskCache) Store(ctx context.Context, ownerID string, srcRef reference.
128135
if layer.Err != nil {
129136
return fmt.Errorf("error reading layer[%d]: %w", layer.Index, layer.Err)
130137
}
131-
if _, err := archive.Apply(ctx, dest, layer.Reader, applyOpts...); err != nil {
132-
return fmt.Errorf("error applying layer[%d]: %w", layer.Index, err)
138+
if layer.MediaType == registry.ChartLayerMediaType {
139+
if features.OperatorControllerFeatureGate.Enabled(features.HelmChartSupport) || testing.Testing() {
140+
if err := storeChartLayer(dest, layer); err != nil {
141+
return err
142+
}
143+
}
144+
} else {
145+
if _, err := archive.Apply(ctx, dest, layer.Reader, applyOpts...); err != nil {
146+
return fmt.Errorf("error applying layer[%d]: %w", layer.Index, err)
147+
}
133148
}
134149
l.Info("applied layer", "layer", layer.Index)
135150
}
@@ -147,6 +162,40 @@ func (a *diskCache) Store(ctx context.Context, ownerID string, srcRef reference.
147162
return os.DirFS(dest), modTime, nil
148163
}
149164

165+
func storeChartLayer(path string, layer LayerData) error {
166+
if layer.Err != nil {
167+
return fmt.Errorf("error found in layer data: %w", layer.Err)
168+
}
169+
data, err := io.ReadAll(layer.Reader)
170+
if err != nil {
171+
return fmt.Errorf("error reading layer[%d]: %w", layer.Index, err)
172+
}
173+
meta := new(chart.Metadata)
174+
_, err = inspectChart(data, meta)
175+
if err != nil {
176+
return fmt.Errorf("inspecting chart layer: %w", err)
177+
}
178+
chart, err := renameio.TempFile("",
179+
filepath.Join(path,
180+
fmt.Sprintf("%s-%s.tgz", meta.Name, meta.Version),
181+
),
182+
)
183+
if err != nil {
184+
return fmt.Errorf("create temp file: %w", err)
185+
}
186+
defer func() {
187+
_ = chart.Cleanup()
188+
}()
189+
if _, err := io.Copy(chart, bytes.NewReader(data)); err != nil {
190+
return fmt.Errorf("copying chart archive: %w", err)
191+
}
192+
_, err = chart.Seek(0, io.SeekStart)
193+
if err != nil {
194+
return fmt.Errorf("seek chart archive start: %w", err)
195+
}
196+
return chart.CloseAtomicallyReplace()
197+
}
198+
150199
func (a *diskCache) Delete(_ context.Context, ownerID string) error {
151200
return fsutil.DeleteReadOnlyRecursive(a.ownerIDPath(ownerID))
152201
}

internal/shared/util/image/cache_test.go

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ package image
22

33
import (
44
"archive/tar"
5+
"bytes"
56
"context"
67
"errors"
8+
"fmt"
79
"io"
810
"io/fs"
911
"iter"
@@ -20,6 +22,7 @@ import (
2022
ocispecv1 "github.com/opencontainers/image-spec/specs-go/v1"
2123
"github.com/stretchr/testify/assert"
2224
"github.com/stretchr/testify/require"
25+
"helm.sh/helm/v3/pkg/registry"
2326

2427
fsutil "github.com/operator-framework/operator-controller/internal/shared/util/fs"
2528
)
@@ -144,6 +147,67 @@ func TestDiskCacheFetch(t *testing.T) {
144147
}
145148
}
146149

150+
func TestDiskCacheStore_HelmChart(t *testing.T) {
151+
const myOwner = "myOwner"
152+
myCanonicalRef := mustParseCanonical(t, "my.registry.io/ns/chart@sha256:5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03")
153+
myTaggedRef, err := reference.WithTag(reference.TrimNamed(myCanonicalRef), "test-tag")
154+
require.NoError(t, err)
155+
156+
testCases := []struct {
157+
name string
158+
ownerID string
159+
srcRef reference.Named
160+
canonicalRef reference.Canonical
161+
imgConfig ocispecv1.Image
162+
layers iter.Seq[LayerData]
163+
filterFunc func(context.Context, reference.Named, ocispecv1.Image) (archive.Filter, error)
164+
setup func(*testing.T, *diskCache)
165+
expect func(*testing.T, *diskCache, fs.FS, time.Time, error)
166+
}{
167+
{
168+
name: "returns no error if layer read contains helm chart",
169+
ownerID: myOwner,
170+
srcRef: myTaggedRef,
171+
canonicalRef: myCanonicalRef,
172+
layers: func() iter.Seq[LayerData] {
173+
testChart := mockHelmChartTgz(t,
174+
[]fileContent{
175+
{
176+
name: "testchart/Chart.yaml",
177+
content: []byte("apiVersion: v2\nname: testchart\nversion: 0.1.0"),
178+
},
179+
{
180+
name: "testchart/templates/deployment.yaml",
181+
content: []byte("kind: Deployment\napiVersion: apps/v1"),
182+
},
183+
},
184+
)
185+
return func(yield func(LayerData) bool) {
186+
yield(LayerData{Reader: bytes.NewBuffer(testChart), MediaType: registry.ChartLayerMediaType})
187+
}
188+
}(),
189+
expect: func(t *testing.T, cache *diskCache, fsys fs.FS, modTime time.Time, err error) {
190+
require.NoError(t, err)
191+
},
192+
},
193+
}
194+
for _, tc := range testCases {
195+
t.Run(tc.name, func(t *testing.T) {
196+
dc := &diskCache{
197+
basePath: t.TempDir(),
198+
filterFunc: tc.filterFunc,
199+
}
200+
if tc.setup != nil {
201+
tc.setup(t, dc)
202+
}
203+
fsys, modTime, err := dc.Store(context.Background(), tc.ownerID, tc.srcRef, tc.canonicalRef, tc.imgConfig, tc.layers)
204+
require.NotNil(t, tc.expect, "test case must include an expect function")
205+
tc.expect(t, dc, fsys, modTime, err)
206+
require.NoError(t, fsutil.DeleteReadOnlyRecursive(dc.basePath))
207+
})
208+
}
209+
}
210+
147211
func TestDiskCacheStore(t *testing.T) {
148212
const myOwner = "myOwner"
149213
myCanonicalRef := mustParseCanonical(t, "my.registry.io/ns/repo@sha256:5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03")
@@ -585,6 +649,120 @@ func TestDiskCacheGarbageCollection(t *testing.T) {
585649
}
586650
}
587651

652+
func Test_storeChartLayer(t *testing.T) {
653+
tmp := t.TempDir()
654+
type args struct {
655+
path string
656+
data LayerData
657+
}
658+
type want struct {
659+
errStr string
660+
}
661+
662+
tests := []struct {
663+
name string
664+
args args
665+
want want
666+
}{
667+
{
668+
name: "store chart layer to given path",
669+
args: args{
670+
path: tmp,
671+
data: LayerData{
672+
Index: 0,
673+
MediaType: registry.ChartLayerMediaType,
674+
Reader: bytes.NewBuffer(mockHelmChartTgz(t,
675+
[]fileContent{
676+
{
677+
name: "testchart/Chart.yaml",
678+
content: []byte("apiVersion: v2\nname: testchart\nversion: 0.1.0"),
679+
},
680+
{
681+
name: "testchart/templates/deployment.yaml",
682+
content: []byte("kind: Deployment\napiVersion: apps/v1"),
683+
},
684+
},
685+
)),
686+
},
687+
},
688+
},
689+
{
690+
name: "store invalid chart layer",
691+
args: args{
692+
path: tmp,
693+
data: LayerData{
694+
Index: 0,
695+
MediaType: registry.ChartLayerMediaType,
696+
Reader: bytes.NewBuffer(mockHelmChartTgz(t,
697+
[]fileContent{
698+
{
699+
name: "testchart/Chart.yaml",
700+
content: []byte("apiVersion: v2\nname: testchart\nversion: 0.1.0"),
701+
},
702+
{
703+
name: "testchart/deployment.yaml",
704+
content: []byte("kind: Deployment\napiVersion: apps/v1"),
705+
},
706+
},
707+
)),
708+
},
709+
},
710+
},
711+
{
712+
name: "store existing from dummy reader",
713+
args: args{
714+
path: tmp,
715+
data: LayerData{
716+
Index: 0,
717+
MediaType: registry.ChartLayerMediaType,
718+
Reader: &dummyReader{},
719+
},
720+
},
721+
want: want{
722+
errStr: "error reading layer[0]: something went wrong",
723+
},
724+
},
725+
{
726+
name: "handle chart layer data",
727+
args: args{
728+
path: tmp,
729+
data: LayerData{
730+
Index: 0,
731+
MediaType: registry.ChartLayerMediaType,
732+
Err: fmt.Errorf("invalid layer data"),
733+
Reader: bytes.NewBuffer(mockHelmChartTgz(t,
734+
[]fileContent{
735+
{
736+
name: "testchart/Chart.yaml",
737+
content: []byte("apiVersion: v2\nname: testchart\nversion: 0.1.0"),
738+
},
739+
{
740+
name: "testchart/deployment.yaml",
741+
content: []byte("kind: Deployment\napiVersion: apps/v1"),
742+
},
743+
},
744+
)),
745+
},
746+
},
747+
want: want{
748+
errStr: "error found in layer data: invalid layer data",
749+
},
750+
},
751+
}
752+
753+
for _, tc := range tests {
754+
t.Run(tc.name, func(t *testing.T) {
755+
err := storeChartLayer(tc.args.path, tc.args.data)
756+
if tc.want.errStr != "" {
757+
require.Error(t, err)
758+
require.EqualError(t, err, tc.want.errStr, "chart store error")
759+
} else {
760+
require.NoError(t, err)
761+
}
762+
})
763+
}
764+
}
765+
588766
func mustParseCanonical(t *testing.T, s string) reference.Canonical {
589767
n, err := reference.ParseNamed(s)
590768
require.NoError(t, err)
@@ -619,3 +797,11 @@ func fsTarReader(fsys fs.FS) io.ReadCloser {
619797
}()
620798
return pr
621799
}
800+
801+
type dummyReader struct{}
802+
803+
var _ io.Reader = &dummyReader{}
804+
805+
func (r *dummyReader) Read(p []byte) (int, error) {
806+
return 0, errors.New("something went wrong")
807+
}

0 commit comments

Comments
 (0)