diff --git a/internal/operator-controller/applier/boxcutter.go b/internal/operator-controller/applier/boxcutter.go index efd2c01a19..ea7d05badd 100644 --- a/internal/operator-controller/applier/boxcutter.go +++ b/internal/operator-controller/applier/boxcutter.go @@ -9,10 +9,14 @@ import ( "io" "io/fs" "maps" + "path/filepath" "slices" "strings" "github.com/cert-manager/cert-manager/pkg/apis/certmanager" + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chartutil" + "helm.sh/helm/v3/pkg/engine" "helm.sh/helm/v3/pkg/release" "helm.sh/helm/v3/pkg/storage/driver" appsv1 "k8s.io/api/apps/v1" @@ -24,6 +28,7 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" + apimachyaml "k8s.io/apimachinery/pkg/util/yaml" "k8s.io/apiserver/pkg/authentication/user" "k8s.io/apiserver/pkg/authorization/authorizer" "k8s.io/cli-runtime/pkg/printers" @@ -39,9 +44,11 @@ import ( ocv1 "github.com/operator-framework/operator-controller/api/v1" ocv1ac "github.com/operator-framework/operator-controller/applyconfigurations/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/labels" "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/bundle/source" "github.com/operator-framework/operator-controller/internal/shared/util/cache" + imageutil "github.com/operator-framework/operator-controller/internal/shared/util/image" ) const ( @@ -120,6 +127,13 @@ func (r *SimpleRevisionGenerator) GenerateRevision( bundleFS fs.FS, ext *ocv1.ClusterExtension, objectLabels, revisionAnnotations map[string]string, ) (*ocv1ac.ClusterObjectSetApplyConfiguration, error) { + if features.OperatorControllerFeatureGate.Enabled(features.HelmChartSupport) { + meta := new(chart.Metadata) + if ok, _ := imageutil.IsBundleSourceChart(bundleFS, meta); ok { + return r.generateRevisionFromChart(ctx, bundleFS, ext, meta, objectLabels, revisionAnnotations) + } + } + // extract plain manifests plain, err := r.ManifestProvider.Get(bundleFS, ext) if err != nil { @@ -189,6 +203,148 @@ func (r *SimpleRevisionGenerator) GenerateRevision( return rev, nil } +func (r *SimpleRevisionGenerator) generateRevisionFromChart( + ctx context.Context, + bundleFS fs.FS, ext *ocv1.ClusterExtension, meta *chart.Metadata, + objectLabels, revisionAnnotations map[string]string, +) (*ocv1ac.ClusterObjectSetApplyConfiguration, error) { + filename, err := findChartArchive(bundleFS) + if err != nil { + return nil, fmt.Errorf("finding chart archive: %w", err) + } + chrt, err := imageutil.LoadChartFSWithOptions(bundleFS, filename) + if err != nil { + return nil, fmt.Errorf("loading helm chart %s: %w", filename, err) + } + + isUpgrade := ext.Status.Install != nil && ext.Status.Install.Bundle.Name != "" + releaseOpts := chartutil.ReleaseOptions{ + Name: ext.GetName(), + Namespace: ext.Spec.Namespace, + Revision: 1, + IsInstall: !isUpgrade, + IsUpgrade: isUpgrade, + } + vals, err := chartutil.ToRenderValues(chrt, chrt.Values, releaseOpts, nil) + if err != nil { + return nil, fmt.Errorf("creating render values: %w", err) + } + + rendered, err := engine.Render(chrt, vals) + if err != nil { + return nil, fmt.Errorf("rendering helm chart templates: %w", err) + } + + plain, err := parseRenderedChart(rendered, chrt) + if err != nil { + return nil, fmt.Errorf("parsing rendered chart: %w", err) + } + + if revisionAnnotations == nil { + revisionAnnotations = map[string]string{} + } + + objs := make([]ocv1ac.ClusterObjectSetObjectApplyConfiguration, 0, len(plain)) + for _, obj := range plain { + obj.SetLabels(mergeStringMaps(obj.GetLabels(), objectLabels)) + + // Memory optimization: strip large annotations + if err := cache.ApplyStripAnnotationsTransform(obj); err != nil { + return nil, err + } + sanitizedUnstructured(ctx, obj) + + annotationUpdates := map[string]string{} + if v := revisionAnnotations[labels.BundleVersionKey]; v != "" { + annotationUpdates[labels.BundleVersionKey] = v + } + if v := revisionAnnotations[labels.PackageNameKey]; v != "" { + annotationUpdates[labels.PackageNameKey] = v + } + if len(annotationUpdates) > 0 { + obj.SetAnnotations(mergeStringMaps(obj.GetAnnotations(), annotationUpdates)) + } + + objs = append(objs, *ocv1ac.ClusterObjectSetObject(). + WithObject(*obj)) + } + rev := r.buildClusterObjectSet(objs, ext, revisionAnnotations) + rev.Spec.WithCollisionProtection(ocv1.CollisionProtectionPrevent) + return rev, nil +} + +func parseRenderedChart(rendered map[string]string, chrt *chart.Chart) ([]*unstructured.Unstructured, error) { + var objects []*unstructured.Unstructured + + // Include CRDs from the crds/ directory (not rendered by engine.Render) + for _, crd := range chrt.CRDObjects() { + objs, err := decodeYAMLDocuments(crd.File.Data) + if err != nil { + return nil, fmt.Errorf("parsing CRD %s: %w", crd.Name, err) + } + objects = append(objects, objs...) + } + + // Sort template names for deterministic output + templateNames := slices.Sorted(maps.Keys(rendered)) + for _, name := range templateNames { + ext := strings.ToLower(filepath.Ext(name)) + if ext != ".yaml" && ext != ".yml" && ext != ".json" { + continue + } + content := strings.TrimSpace(rendered[name]) + if content == "" { + continue + } + objs, err := decodeYAMLDocuments([]byte(content)) + if err != nil { + return nil, fmt.Errorf("parsing template %s: %w", name, err) + } + for _, obj := range objs { + if _, isHook := obj.GetAnnotations()["helm.sh/hook"]; isHook { + continue + } + objects = append(objects, obj) + } + } + + return objects, nil +} + +func decodeYAMLDocuments(data []byte) ([]*unstructured.Unstructured, error) { + var objects []*unstructured.Unstructured + dec := apimachyaml.NewYAMLOrJSONDecoder(bytes.NewReader(data), 1024) + for { + obj := unstructured.Unstructured{} + err := dec.Decode(&obj) + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, err + } + if len(obj.Object) == 0 { + continue + } + objects = append(objects, &obj) + } + return objects, nil +} + +func findChartArchive(bundleFS fs.FS) (string, error) { + entries, err := fs.ReadDir(bundleFS, ".") + if err != nil { + return "", fmt.Errorf("reading bundle directory: %w", err) + } + for _, entry := range entries { + name := entry.Name() + if strings.HasSuffix(name, ".tgz") || strings.HasSuffix(name, ".tar.gz") { + return name, nil + } + } + return "", fmt.Errorf("no chart archive (.tgz or .tar.gz) found in bundle") +} + // sanitizedUnstructured takes an unstructured obj, removes status if present, and returns a sanitized copy containing only the allowed metadata entries set below. // If any unallowed entries are removed, a warning will be logged. func sanitizedUnstructured(ctx context.Context, unstr *unstructured.Unstructured) { diff --git a/internal/operator-controller/applier/boxcutter_test.go b/internal/operator-controller/applier/boxcutter_test.go index 8814a61e53..06e66f6674 100644 --- a/internal/operator-controller/applier/boxcutter_test.go +++ b/internal/operator-controller/applier/boxcutter_test.go @@ -1,11 +1,16 @@ package applier_test import ( + "archive/tar" + "bytes" + "compress/gzip" "context" "errors" "fmt" "io" "io/fs" + "os" + "path/filepath" "strings" "testing" "testing/fstest" @@ -36,6 +41,7 @@ import ( ocv1ac "github.com/operator-framework/operator-controller/applyconfigurations/api/v1" "github.com/operator-framework/operator-controller/internal/operator-controller/applier" "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/labels" bundlecsv "github.com/operator-framework/operator-controller/internal/testing/bundle/csv" bundlefs "github.com/operator-framework/operator-controller/internal/testing/bundle/fs" @@ -1861,3 +1867,369 @@ func TestBoxcutterStorageMigrator(t *testing.T) { require.NoError(t, err) }) } + +type testChartFile struct { + name string + content []byte +} + +func makeChartTgz(t *testing.T, files []testChartFile) []byte { + t.Helper() + require.NotEmpty(t, files, "chart content required") + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + for _, f := range files { + require.NoError(t, tw.WriteHeader(&tar.Header{ + Name: f.name, + Mode: 0600, + Size: int64(len(f.content)), + })) + _, err := tw.Write(f.content) + require.NoError(t, err) + } + require.NoError(t, tw.Close()) + + var gzBuf bytes.Buffer + gz := gzip.NewWriter(&gzBuf) + _, err := gz.Write(buf.Bytes()) + require.NoError(t, err) + require.NoError(t, gz.Close()) + return gzBuf.Bytes() +} + +func makeChartFS(t *testing.T, tgzData []byte) fs.FS { + t.Helper() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "testchart-0.1.0.tgz"), tgzData, 0600)) + return os.DirFS(dir) +} + +func Test_SimpleRevisionGenerator_GenerateRevision_HelmChart(t *testing.T) { + require.NoError(t, features.OperatorControllerFeatureGate.Set(fmt.Sprintf("%s=true", features.HelmChartSupport))) + t.Cleanup(func() { + require.NoError(t, features.OperatorControllerFeatureGate.Set(fmt.Sprintf("%s=false", features.HelmChartSupport))) + }) + + chartYAML := `apiVersion: v2 +name: testchart +version: 0.1.0` + configmapTemplate := `apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Release.Name }}-config + namespace: {{ .Release.Namespace }} +data: + version: "0.1.0"` + + tgz := makeChartTgz(t, []testChartFile{ + {name: "testchart/Chart.yaml", content: []byte(chartYAML)}, + {name: "testchart/templates/configmap.yaml", content: []byte(configmapTemplate)}, + }) + bundleFS := makeChartFS(t, tgz) + + // ManifestProvider should NOT be called — strict mock with no expectations + ctrl := gomock.NewController(t) + mp := mockapplier.NewMockManifestProvider(ctrl) + + b := applier.SimpleRevisionGenerator{ + Scheme: k8scheme.Scheme, + ManifestProvider: mp, + } + + ext := &ocv1.ClusterExtension{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-extension", + }, + Spec: ocv1.ClusterExtensionSpec{ + Namespace: "test-namespace", + ServiceAccount: ocv1.ServiceAccountReference{ + Name: "test-sa", + }, + }, + } + + rev, err := b.GenerateRevision(t.Context(), bundleFS, ext, map[string]string{}, map[string]string{ + labels.BundleVersionKey: "0.1.0", + labels.PackageNameKey: "test-package", + }) + require.NoError(t, err) + require.NotNil(t, rev) + + // Verify the rendered ConfigMap is present in one of the phases + var foundConfigMap bool + for _, phase := range rev.Spec.Phases { + for _, obj := range phase.Objects { + if obj.Object.GetKind() == "ConfigMap" && obj.Object.GetName() == "test-extension-config" { + foundConfigMap = true + require.Equal(t, "test-namespace", obj.Object.GetNamespace()) + } + } + } + require.True(t, foundConfigMap, "expected rendered ConfigMap in revision phases") +} + +func Test_SimpleRevisionGenerator_GenerateRevision_HelmChart_FeatureDisabled(t *testing.T) { + // HelmChartSupport is disabled by default — no setup needed + + chartYAML := `apiVersion: v2 +name: testchart +version: 0.1.0` + + tgz := makeChartTgz(t, []testChartFile{ + {name: "testchart/Chart.yaml", content: []byte(chartYAML)}, + {name: "testchart/templates/configmap.yaml", content: []byte("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: test")}, + }) + bundleFS := makeChartFS(t, tgz) + + // With feature disabled, ManifestProvider.Get() is called, which will fail + // on a helm chart bundle (no metadata/annotations.yaml) + ctrl := gomock.NewController(t) + mp := mockapplier.NewMockManifestProvider(ctrl) + mp.EXPECT().Get(gomock.Any(), gomock.Any()).Return(nil, fmt.Errorf("open metadata/annotations.yaml: no such file or directory")) + + b := applier.SimpleRevisionGenerator{ + Scheme: k8scheme.Scheme, + ManifestProvider: mp, + } + + ext := &ocv1.ClusterExtension{ + ObjectMeta: metav1.ObjectMeta{Name: "test-extension"}, + Spec: ocv1.ClusterExtensionSpec{ + Namespace: "test-namespace", + ServiceAccount: ocv1.ServiceAccountReference{Name: "test-sa"}, + }, + } + + _, err := b.GenerateRevision(t.Context(), bundleFS, ext, nil, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "metadata/annotations.yaml") +} + +func Test_SimpleRevisionGenerator_GenerateRevision_RegistryV1_WithHelmFeature(t *testing.T) { + require.NoError(t, features.OperatorControllerFeatureGate.Set(fmt.Sprintf("%s=true", features.HelmChartSupport))) + t.Cleanup(func() { + require.NoError(t, features.OperatorControllerFeatureGate.Set(fmt.Sprintf("%s=false", features.HelmChartSupport))) + }) + + // Use a registryv1 bundle — IsBundleSourceChart should return false + ctrl := gomock.NewController(t) + mp := mockapplier.NewMockManifestProvider(ctrl) + mp.EXPECT().Get(gomock.Any(), gomock.Any()).Return([]client.Object{ + &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "test-cm"}, + }, + }, nil) + + b := applier.SimpleRevisionGenerator{ + Scheme: k8scheme.Scheme, + ManifestProvider: mp, + } + + ext := &ocv1.ClusterExtension{ + ObjectMeta: metav1.ObjectMeta{Name: "test-extension"}, + Spec: ocv1.ClusterExtensionSpec{ + Namespace: "test-namespace", + ServiceAccount: ocv1.ServiceAccountReference{Name: "test-sa"}, + }, + } + + rev, err := b.GenerateRevision(t.Context(), dummyBundle, ext, map[string]string{}, map[string]string{}) + require.NoError(t, err) + require.NotNil(t, rev) + + // Verify the registryv1 path was used (ManifestProvider was called) + var foundCM bool + for _, phase := range rev.Spec.Phases { + for _, obj := range phase.Objects { + if obj.Object.GetKind() == "ConfigMap" && obj.Object.GetName() == "test-cm" { + foundCM = true + } + } + } + require.True(t, foundCM, "expected ConfigMap from ManifestProvider in revision phases") +} + +func Test_SimpleRevisionGenerator_GenerateRevision_HelmChart_SkipsNonManifestTemplates(t *testing.T) { + require.NoError(t, features.OperatorControllerFeatureGate.Set(fmt.Sprintf("%s=true", features.HelmChartSupport))) + t.Cleanup(func() { + require.NoError(t, features.OperatorControllerFeatureGate.Set(fmt.Sprintf("%s=false", features.HelmChartSupport))) + }) + + chartYAML := `apiVersion: v2 +name: testchart +version: 0.1.0` + configmapTemplate := `apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Release.Name }}-config +data: + key: value` + notesTemplate := `Thank you for installing {{ .Chart.Name }}. +Your release is named {{ .Release.Name }}. +To learn more: visit https://example.com` + helpersTemplate := `{{- define "testchart.fullname" -}} +{{- .Release.Name }}-{{ .Chart.Name }} +{{- end }}` + + tgz := makeChartTgz(t, []testChartFile{ + {name: "testchart/Chart.yaml", content: []byte(chartYAML)}, + {name: "testchart/templates/configmap.yaml", content: []byte(configmapTemplate)}, + {name: "testchart/templates/NOTES.txt", content: []byte(notesTemplate)}, + {name: "testchart/templates/_helpers.tpl", content: []byte(helpersTemplate)}, + }) + bundleFS := makeChartFS(t, tgz) + + ctrl := gomock.NewController(t) + mp := mockapplier.NewMockManifestProvider(ctrl) + + b := applier.SimpleRevisionGenerator{ + Scheme: k8scheme.Scheme, + ManifestProvider: mp, + } + + ext := &ocv1.ClusterExtension{ + ObjectMeta: metav1.ObjectMeta{Name: "test-extension"}, + Spec: ocv1.ClusterExtensionSpec{ + Namespace: "test-namespace", + ServiceAccount: ocv1.ServiceAccountReference{Name: "test-sa"}, + }, + } + + rev, err := b.GenerateRevision(t.Context(), bundleFS, ext, nil, nil) + require.NoError(t, err) + require.NotNil(t, rev) + + // Count total objects — only the configmap.yaml should produce an object; + // NOTES.txt and _helpers.tpl must be skipped + var objectCount int + for _, phase := range rev.Spec.Phases { + objectCount += len(phase.Objects) + } + require.Equal(t, 1, objectCount, "expected exactly 1 object (ConfigMap); NOTES.txt and _helpers.tpl should be skipped") +} + +func Test_SimpleRevisionGenerator_GenerateRevision_HelmChart_UsesChartValues(t *testing.T) { + require.NoError(t, features.OperatorControllerFeatureGate.Set(fmt.Sprintf("%s=true", features.HelmChartSupport))) + t.Cleanup(func() { + require.NoError(t, features.OperatorControllerFeatureGate.Set(fmt.Sprintf("%s=false", features.HelmChartSupport))) + }) + + chartYAML := `apiVersion: v2 +name: testchart +version: 0.1.0` + valuesYAML := `replicaCount: 3 +appName: my-app` + configmapTemplate := `apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Release.Name }}-config +data: + replicas: "{{ .Values.replicaCount }}" + app: "{{ .Values.appName }}"` + + tgz := makeChartTgz(t, []testChartFile{ + {name: "testchart/Chart.yaml", content: []byte(chartYAML)}, + {name: "testchart/values.yaml", content: []byte(valuesYAML)}, + {name: "testchart/templates/configmap.yaml", content: []byte(configmapTemplate)}, + }) + bundleFS := makeChartFS(t, tgz) + + ctrl := gomock.NewController(t) + mp := mockapplier.NewMockManifestProvider(ctrl) + + b := applier.SimpleRevisionGenerator{ + Scheme: k8scheme.Scheme, + ManifestProvider: mp, + } + + ext := &ocv1.ClusterExtension{ + ObjectMeta: metav1.ObjectMeta{Name: "test-extension"}, + Spec: ocv1.ClusterExtensionSpec{ + Namespace: "test-namespace", + ServiceAccount: ocv1.ServiceAccountReference{Name: "test-sa"}, + }, + } + + rev, err := b.GenerateRevision(t.Context(), bundleFS, ext, nil, nil) + require.NoError(t, err) + require.NotNil(t, rev) + + var found bool + for _, phase := range rev.Spec.Phases { + for _, obj := range phase.Objects { + if obj.Object.GetKind() == "ConfigMap" { + found = true + data, _, _ := unstructured.NestedStringMap(obj.Object.Object, "data") + require.Equal(t, "3", data["replicas"], "values.yaml replicaCount should be rendered") + require.Equal(t, "my-app", data["app"], "values.yaml appName should be rendered") + } + } + } + require.True(t, found, "expected ConfigMap with rendered values") +} + +func Test_SimpleRevisionGenerator_GenerateRevision_HelmChart_SkipsHooks(t *testing.T) { + require.NoError(t, features.OperatorControllerFeatureGate.Set(fmt.Sprintf("%s=true", features.HelmChartSupport))) + t.Cleanup(func() { + require.NoError(t, features.OperatorControllerFeatureGate.Set(fmt.Sprintf("%s=false", features.HelmChartSupport))) + }) + + chartYAML := `apiVersion: v2 +name: testchart +version: 0.1.0` + configmapTemplate := `apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Release.Name }}-config +data: + key: value` + hookTemplate := `apiVersion: batch/v1 +kind: Job +metadata: + name: {{ .Release.Name }}-migrate + annotations: + "helm.sh/hook": pre-install + "helm.sh/hook-delete-policy": hook-succeeded +spec: + template: + spec: + containers: + - name: migrate + image: busybox + restartPolicy: Never` + + tgz := makeChartTgz(t, []testChartFile{ + {name: "testchart/Chart.yaml", content: []byte(chartYAML)}, + {name: "testchart/templates/configmap.yaml", content: []byte(configmapTemplate)}, + {name: "testchart/templates/pre-install-job.yaml", content: []byte(hookTemplate)}, + }) + bundleFS := makeChartFS(t, tgz) + + ctrl := gomock.NewController(t) + mp := mockapplier.NewMockManifestProvider(ctrl) + + b := applier.SimpleRevisionGenerator{ + Scheme: k8scheme.Scheme, + ManifestProvider: mp, + } + + ext := &ocv1.ClusterExtension{ + ObjectMeta: metav1.ObjectMeta{Name: "test-extension"}, + Spec: ocv1.ClusterExtensionSpec{ + Namespace: "test-namespace", + ServiceAccount: ocv1.ServiceAccountReference{Name: "test-sa"}, + }, + } + + rev, err := b.GenerateRevision(t.Context(), bundleFS, ext, nil, nil) + require.NoError(t, err) + + var objectCount int + for _, phase := range rev.Spec.Phases { + for _, obj := range phase.Objects { + objectCount++ + require.Equal(t, "ConfigMap", obj.Object.GetKind(), "only ConfigMap should remain; hook Job should be filtered out") + } + } + require.Equal(t, 1, objectCount, "expected exactly 1 object (ConfigMap); hook Job should be filtered out") +} diff --git a/test/e2e/features/install.feature b/test/e2e/features/install.feature index e82ad10e1d..27883a6c4c 100644 --- a/test/e2e/features/install.feature +++ b/test/e2e/features/install.feature @@ -678,3 +678,33 @@ Feature: Install ClusterExtension Then ClusterExtension is available And ClusterExtension reports Progressing as True with Reason Succeeded And ClusterExtension reports Installed as True + + @HelmChartSupport + @BoxcutterRuntime + Scenario: Install Helm chart from catalog + Given a catalog "test" with packages: + | package | version | channel | chart | + | helm-test | 1.0.0 | stable | helm-test | + And ServiceAccount "olm-sa" with needed permissions is available in test namespace + When ClusterExtension is applied + """ + apiVersion: olm.operatorframework.io/v1 + kind: ClusterExtension + metadata: + name: ${NAME} + spec: + namespace: ${TEST_NAMESPACE} + serviceAccount: + name: olm-sa + source: + sourceType: Catalog + catalog: + packageName: ${PACKAGE:helm-test} + version: 1.0.0 + selector: + matchLabels: + "olm.operatorframework.io/metadata.name": ${CATALOG:test} + """ + Then ClusterExtension is rolled out + And ClusterExtension is available + And resource "configmap/${NAME}-config" is installed diff --git a/test/e2e/steps/steps.go b/test/e2e/steps/steps.go index 00a10ea64f..4bbb6d17fb 100644 --- a/test/e2e/steps/steps.go +++ b/test/e2e/steps/steps.go @@ -1587,12 +1587,24 @@ func parseCatalogTable(table *godog.Table) ([]catalog.PackageOption, error) { var packageOrder []string packageSeen := make(map[string]bool) - for _, row := range table.Rows[1:] { // skip header - pkg := row.Cells[0].Value - version := row.Cells[1].Value - channel := row.Cells[2].Value - replaces := row.Cells[3].Value - contents := row.Cells[4].Value + headerCols := make(map[string]int) + for i, cell := range table.Rows[0].Cells { + headerCols[strings.ToLower(cell.Value)] = i + } + cellVal := func(rowIdx int, name string) string { + if idx, ok := headerCols[name]; ok && idx < len(table.Rows[rowIdx].Cells) { + return table.Rows[rowIdx].Cells[idx].Value + } + return "" + } + + for rowIdx := 1; rowIdx < len(table.Rows); rowIdx++ { + pkg := cellVal(rowIdx, "package") + version := cellVal(rowIdx, "version") + channel := cellVal(rowIdx, "channel") + replaces := cellVal(rowIdx, "replaces") + contents := cellVal(rowIdx, "contents") + chartDir := cellVal(rowIdx, "chart") if !packageSeen[pkg] { packageOrder = append(packageOrder, pkg) @@ -1609,6 +1621,10 @@ func parseCatalogTable(table *godog.Table) ([]catalog.PackageOption, error) { if err != nil { return nil, fmt.Errorf("bundle %s/%s: %w", pkg, version, err) } + if chartDir != "" { + absDir := filepath.Join(projectRootDir(), "test/e2e/steps/testdata/charts", chartDir) + opts = append(opts, catalog.WithHelmChartDir(absDir)) + } bundleDefs[bk] = &bundleEntry{opts: opts} bundleOrder = append(bundleOrder, bk) } diff --git a/test/e2e/steps/testdata/charts/helm-test/Chart.yaml b/test/e2e/steps/testdata/charts/helm-test/Chart.yaml new file mode 100644 index 0000000000..5e96c32b04 --- /dev/null +++ b/test/e2e/steps/testdata/charts/helm-test/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v2 +name: helm-test +version: 1.0.0 +description: Minimal chart for e2e testing of HelmChartSupport diff --git a/test/e2e/steps/testdata/charts/helm-test/templates/configmap.yaml b/test/e2e/steps/testdata/charts/helm-test/templates/configmap.yaml new file mode 100644 index 0000000000..5d398b521f --- /dev/null +++ b/test/e2e/steps/testdata/charts/helm-test/templates/configmap.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Release.Name }}-config + namespace: {{ .Release.Namespace }} +data: + chart: {{ .Chart.Name }} + version: {{ .Chart.Version }} diff --git a/test/internal/catalog/bundle.go b/test/internal/catalog/bundle.go index 7bb80b5bce..0e55d195cc 100644 --- a/test/internal/catalog/bundle.go +++ b/test/internal/catalog/bundle.go @@ -1,6 +1,9 @@ package catalog import ( + "archive/tar" + "bytes" + "compress/gzip" "fmt" "os" "path/filepath" @@ -16,6 +19,7 @@ import ( "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/util/intstr" "k8s.io/utils/ptr" + "sigs.k8s.io/yaml" "github.com/operator-framework/api/pkg/operators/v1alpha1" @@ -41,6 +45,7 @@ type bundleConfig struct { largeCRDFieldCount int // if > 0, generate a CRD with this many fields staticBundleDir string // if set, read bundle from this directory (no parameterization) clusterRegistryOverride string // if set, use this host in the FBC image ref instead of the default + helmChartDir string // if set, package chart from this directory into a .tgz bundle } // bundleSpec is the resolved bundle: version + file map ready for crane.Image(). @@ -48,6 +53,7 @@ type bundleSpec struct { version string files map[string][]byte clusterRegistryOverride string // if set, use this host in the FBC image ref + isHelmChart bool // if set, skip registryv1 OCI labels } // WithCRD includes a CRD in the bundle. @@ -92,6 +98,14 @@ func WithClusterRegistry(host string) BundleOption { } } +// WithHelmChartDir packages the chart at the given directory into a .tgz bundle. +// The directory must contain Chart.yaml and a templates/ subdirectory. +func WithHelmChartDir(dir string) BundleOption { + return func(c *bundleConfig) { + c.helmChartDir = dir + } +} + // StaticBundleDir reads pre-built bundle manifests from the given directory. // The bundle content is NOT parameterized — resource names remain as-is. // Use this for bundles with real operator binaries that can't have their @@ -127,6 +141,20 @@ func buildBundle(scenarioID, packageName, version string, opts []BundleOption) ( o(cfg) } + // Helm chart bundle: package a chart directory into a .tgz + if cfg.helmChartDir != "" { + tgz, chartName, err := packageHelmChart(cfg.helmChartDir) + if err != nil { + return bundleSpec{}, fmt.Errorf("failed to package helm chart from %s: %w", cfg.helmChartDir, err) + } + filename := fmt.Sprintf("%s-%s.tgz", chartName, version) + return bundleSpec{ + version: version, + files: map[string][]byte{filename: tgz}, + isHelmChart: true, + }, nil + } + // Static bundle: read files from disk without parameterization if cfg.staticBundleDir != "" { files, err := readBundleDir(cfg.staticBundleDir) @@ -473,3 +501,66 @@ func readBundleDir(dir string) (map[string][]byte, error) { }) return files, err } + +// packageHelmChart reads chart files from dir and produces a .tgz archive. +// Returns the archive bytes and the chart name parsed from Chart.yaml. +func packageHelmChart(dir string) ([]byte, string, error) { + chartYAML, err := os.ReadFile(filepath.Join(dir, "Chart.yaml")) + if err != nil { + return nil, "", fmt.Errorf("reading Chart.yaml: %w", err) + } + var meta struct { + Name string `json:"name"` + } + if err := yaml.Unmarshal(chartYAML, &meta); err != nil { + return nil, "", fmt.Errorf("parsing Chart.yaml: %w", err) + } + if meta.Name == "" { + return nil, "", fmt.Errorf("Chart.yaml missing required 'name' field") + } + chartName := meta.Name + + var tarBuf bytes.Buffer + tw := tar.NewWriter(&tarBuf) + err = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + rel, err := filepath.Rel(dir, path) + if err != nil { + return err + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + if err := tw.WriteHeader(&tar.Header{ + Name: chartName + "/" + rel, + Mode: 0600, + Size: int64(len(data)), + }); err != nil { + return err + } + _, err = tw.Write(data) + return err + }) + if err != nil { + return nil, "", err + } + if err := tw.Close(); err != nil { + return nil, "", err + } + + var gzBuf bytes.Buffer + gz := gzip.NewWriter(&gzBuf) + if _, err := gz.Write(tarBuf.Bytes()); err != nil { + return nil, "", err + } + if err := gz.Close(); err != nil { + return nil, "", err + } + return gzBuf.Bytes(), chartName, nil +} diff --git a/test/internal/catalog/catalog.go b/test/internal/catalog/catalog.go index 80e38a7ad2..78b1934088 100644 --- a/test/internal/catalog/catalog.go +++ b/test/internal/catalog/catalog.go @@ -142,16 +142,18 @@ func (c *Catalog) Build(ctx context.Context, tag, localRegistry, clusterRegistry return nil, fmt.Errorf("failed to create bundle image for %s:%s: %w", paramPkgName, bd.version, err) } - labels := map[string]string{ - "operators.operatorframework.io.bundle.mediatype.v1": "registry+v1", - "operators.operatorframework.io.bundle.manifests.v1": "manifests/", - "operators.operatorframework.io.bundle.metadata.v1": "metadata/", - "operators.operatorframework.io.bundle.package.v1": paramPkgName, - "operators.operatorframework.io.bundle.channels.v1": "default", - } - img, err = mutate.Config(img, v1.Config{Labels: labels}) - if err != nil { - return nil, fmt.Errorf("failed to set bundle labels for %s:%s: %w", paramPkgName, bd.version, err) + if !spec.isHelmChart { + labels := map[string]string{ + "operators.operatorframework.io.bundle.mediatype.v1": "registry+v1", + "operators.operatorframework.io.bundle.manifests.v1": "manifests/", + "operators.operatorframework.io.bundle.metadata.v1": "metadata/", + "operators.operatorframework.io.bundle.package.v1": paramPkgName, + "operators.operatorframework.io.bundle.channels.v1": "default", + } + img, err = mutate.Config(img, v1.Config{Labels: labels}) + if err != nil { + return nil, fmt.Errorf("failed to set bundle labels for %s:%s: %w", paramPkgName, bd.version, err) + } } bundleTag := fmt.Sprintf("%s/bundles/%s:v%s", localRegistry, paramPkgName, bd.version)