From 42942954a148ce0be5de798dffcb5f76036eab18 Mon Sep 17 00:00:00 2001 From: Gergely Brautigam <182850+Skarlso@users.noreply.github.com> Date: Fri, 3 May 2024 15:42:04 +0200 Subject: [PATCH] feat: add a check for helm chart resources existing before creating helm resources (#413) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Closes https://github.com/open-component-model/ocm-project/issues/64 ## What type of PR is this? (check all applicable) - [ ] 🍕 Feature - [ ] 🐛 Bug Fix - [ ] 📝 Documentation Update - [ ] 🎨 Style - [ ] 🧑‍💻 Code Refactor - [ ] 🔥 Performance Improvements - [ ] ✅ Test - [ ] 🤖 Build - [ ] 🔁 CI - [ ] 📦 Chore (Release) - [ ] ⏩ Revert ## Related Tickets & Documents - Related Issue # (issue) - Closes # (issue) - Fixes # (issue) > Remove if not applicable ## Screenshots ## Added tests? - [ ] 👍 yes - [ ] 🙅 no, because they aren't needed - [ ] 🙋 no, because I need help - [ ] Separate ticket for tests # (issue/pr) Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration ## Added to documentation? - [ ] 📜 README.md - [ ] 🙅 no documentation needed ## Checklist: - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my code - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --- controllers/fluxdeployer_controller.go | 79 ++++++++++++- controllers/fluxdeployer_controller_test.go | 117 ++++++++++++++++++++ controllers/testdata/podinfo-6.3.5.tgz | Bin 0 -> 14169 bytes main.go | 1 + 4 files changed, 194 insertions(+), 3 deletions(-) create mode 100644 controllers/fluxdeployer_controller_test.go create mode 100644 controllers/testdata/podinfo-6.3.5.tgz diff --git a/controllers/fluxdeployer_controller.go b/controllers/fluxdeployer_controller.go index 4076c800..c8af444a 100644 --- a/controllers/fluxdeployer_controller.go +++ b/controllers/fluxdeployer_controller.go @@ -5,15 +5,23 @@ package controllers import ( + "bytes" "context" "errors" "fmt" + "io" + "os" + "path/filepath" "strings" "time" + "github.com/containers/image/v5/pkg/compression" helmv1 "github.com/fluxcd/helm-controller/api/v2beta1" + "github.com/mandelsoft/vfs/pkg/osfs" + "github.com/open-component-model/ocm-controller/pkg/cache" "github.com/open-component-model/ocm-controller/pkg/metrics" "github.com/open-component-model/ocm-controller/pkg/status" + "github.com/open-component-model/ocm/pkg/utils/tarutils" mh "github.com/open-component-model/pkg/metrics" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -55,14 +63,13 @@ type FluxDeployerReconciler struct { DynamicClient dynamic.Interface CertSecretName string + Cache cache.Cache } // +kubebuilder:rbac:groups=delivery.ocm.software,resources=fluxdeployers,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=delivery.ocm.software,resources=fluxdeployers/status,verbs=get;update;patch // +kubebuilder:rbac:groups=delivery.ocm.software,resources=fluxdeployers/finalizers,verbs=update - // +kubebuilder:rbac:groups=delivery.ocm.software,resources=snapshots,verbs=get;list;watch;create;update;patch;delete - // +kubebuilder:rbac:groups=source.toolkit.fluxcd.io,resources=ocirepositories;helmrepositories,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=kustomize.toolkit.fluxcd.io,resources=kustomizations,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=helm.toolkit.fluxcd.io,resources=helmreleases,verbs=get;list;watch;create;update;patch;delete @@ -180,7 +187,7 @@ func (r *FluxDeployerReconciler) reconcile( // create kustomization if obj.Spec.KustomizationTemplate != nil { - // create oci registry + // can't check for helm content as we don't know where things are or what content to check for if err := r.createKustomizationSources(ctx, obj, snapshotURL, snapshot.Spec.Tag); err != nil { msg := "failed to create kustomization sources" logger.Error(err, msg) @@ -202,6 +209,15 @@ func (r *FluxDeployerReconciler) reconcile( } if obj.Spec.HelmReleaseTemplate != nil { + ok, err := r.checkForHelmContent(ctx, obj, snapshot) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to check snapshot content for helm resources: %w", err) + } + + if !ok { + return ctrl.Result{}, fmt.Errorf("no helm chart found for helm content") + } + if err := r.createHelmSources(ctx, obj, snapshotURL); err != nil { msg := "failed to create helm sources" logger.Error(err, msg) @@ -247,6 +263,63 @@ func (r *FluxDeployerReconciler) createKustomizationSources( return nil } +func (r *FluxDeployerReconciler) checkForHelmContent( + ctx context.Context, + deployer *v1alpha1.FluxDeployer, + snapshot *v1alpha1.Snapshot, +) (bool, error) { + data, err := r.getSnapshotBytes(ctx, snapshot) + if err != nil { + return false, fmt.Errorf("failed to get snapshot bytes: %w", err) + } + + virtualFS, err := osfs.NewTempFileSystem() + if err != nil { + return false, fmt.Errorf("fs error: %w", err) + } + defer func() { + _ = virtualFS.RemoveAll("/") + }() + + if err := tarutils.ExtractTarToFs(virtualFS, bytes.NewBuffer(data)); err != nil { + return false, fmt.Errorf("extract tar error: %w", err) + } + + if deployer.Spec.HelmReleaseTemplate == nil { + return false, fmt.Errorf("no helm release template") + } + + chartName := deployer.Spec.HelmReleaseTemplate.Chart.Spec.Chart + + if _, err := virtualFS.Stat(filepath.Join(chartName, "Chart.yaml")); err != nil && !os.IsNotExist(err) { + return false, fmt.Errorf("failed to check for chart yaml: %w", err) + } + + return true, nil +} + +// This might be problematic if the resource is too large in the snapshot. ReadAll will read it into memory. +func (r *FluxDeployerReconciler) getSnapshotBytes(ctx context.Context, snapshot *v1alpha1.Snapshot) ([]byte, error) { + name, err := ocm.ConstructRepositoryName(snapshot.Spec.Identity) + if err != nil { + return nil, fmt.Errorf("failed to construct name: %w", err) + } + + reader, err := r.Cache.FetchDataByDigest(ctx, name, snapshot.Status.LastReconciledDigest) + if err != nil { + return nil, fmt.Errorf("failed to fetch data: %w", err) + } + + uncompressed, _, err := compression.AutoDecompress(reader) + if err != nil { + return nil, fmt.Errorf("failed to auto decompress: %w", err) + } + defer uncompressed.Close() + + // We don't decompress snapshots because those are archives and are decompressed by the caching layer already. + return io.ReadAll(uncompressed) +} + func (r *FluxDeployerReconciler) createHelmSources( ctx context.Context, obj *v1alpha1.FluxDeployer, diff --git a/controllers/fluxdeployer_controller_test.go b/controllers/fluxdeployer_controller_test.go new file mode 100644 index 00000000..0f984884 --- /dev/null +++ b/controllers/fluxdeployer_controller_test.go @@ -0,0 +1,117 @@ +package controllers + +import ( + "context" + "os" + "path/filepath" + "testing" + + helmv1 "github.com/fluxcd/helm-controller/api/v2beta1" + kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1beta2" + "github.com/fluxcd/pkg/apis/meta" + "github.com/fluxcd/pkg/runtime/conditions" + sourcev1beta2 "github.com/fluxcd/source-controller/api/v1beta2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + + "github.com/open-component-model/ocm-controller/api/v1alpha1" + "github.com/open-component-model/ocm-controller/pkg/cache/fakes" + ocmmetav1 "github.com/open-component-model/ocm/pkg/contexts/ocm/compdesc/meta/v1" +) + +func TestFluxDeployerReconcile(t *testing.T) { + resourceV1 := &v1alpha1.Resource{ + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-resource", + Namespace: "default", + }, + Status: v1alpha1.ResourceStatus{ + SnapshotName: "test-snapshot", + }, + } + deployer := &v1alpha1.FluxDeployer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "deployer", + Namespace: "default", + }, + Spec: v1alpha1.FluxDeployerSpec{ + SourceRef: v1alpha1.ObjectReference{ + NamespacedObjectKindReference: meta.NamespacedObjectKindReference{ + Name: "test-resource", + Namespace: "default", + Kind: "Resource", + }, + }, + HelmReleaseTemplate: &helmv1.HelmReleaseSpec{ + Chart: helmv1.HelmChartTemplate{ + Spec: helmv1.HelmChartTemplateSpec{ + Chart: "podinfo", + Version: "6.3.5", + }, + }, + }, + }, + } + conditions.MarkTrue(deployer, meta.ReadyCondition, meta.SucceededReason, "done") + snapshot := &v1alpha1.Snapshot{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-snapshot", + Namespace: "default", + }, + Spec: v1alpha1.SnapshotSpec{ + Identity: ocmmetav1.Identity{ + v1alpha1.ComponentNameKey: "component-name", + v1alpha1.ComponentVersionKey: "v0.0.1", + v1alpha1.ResourceNameKey: "resource-name", + v1alpha1.ResourceVersionKey: "v0.0.5", + v1alpha1.ResourceHelmChartNameKey: "podinfo", + }, + Digest: "digest-1", + Tag: "1234", + }, + Status: v1alpha1.SnapshotStatus{ + LastReconciledDigest: "digest-1", + LastReconciledTag: "1234", + }, + } + conditions.MarkTrue(snapshot, meta.ReadyCondition, meta.SucceededReason, "Snapshot with name '%s' is ready", snapshot.Name) + + client := env.FakeKubeClient( + WithAddToScheme(helmv1.AddToScheme), + WithAddToScheme(sourcev1beta2.AddToScheme), + WithAddToScheme(kustomizev1.AddToScheme), + WithObjects(snapshot, deployer, resourceV1), + ) + fakeCache := &fakes.FakeCache{} + content, err := os.Open(filepath.Join("testdata", "podinfo-6.3.5.tgz")) + require.NoError(t, err) + fakeCache.FetchDataByDigestReturns(content, nil) + recorder := record.NewFakeRecorder(32) + dc := env.FakeDynamicKubeClient(WithObjects(snapshot, deployer, resourceV1)) + + sr := FluxDeployerReconciler{ + Client: client, + Scheme: env.scheme, + EventRecorder: recorder, + RegistryServiceName: "127.0.0.1:5000", + RetryInterval: 0, + DynamicClient: dc, + Cache: fakeCache, + } + + result, err := sr.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{ + Name: deployer.Name, + Namespace: deployer.Namespace, + }, + }) + require.NoError(t, err) + assert.Equal(t, ctrl.Result{}, result) + + close(recorder.Events) +} diff --git a/controllers/testdata/podinfo-6.3.5.tgz b/controllers/testdata/podinfo-6.3.5.tgz new file mode 100644 index 0000000000000000000000000000000000000000..df0424f5599470cabc3ab22aa491fca46a8ba2fd GIT binary patch literal 14169 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 jd0dS_;JvTy06bsM*Yov%?DhW#00960Dm@BL04M