diff --git a/api/v2/helmrelease_types.go b/api/v2/helmrelease_types.go
index 73cea73a4..7bc1f4028 100644
--- a/api/v2/helmrelease_types.go
+++ b/api/v2/helmrelease_types.go
@@ -170,6 +170,12 @@ type HelmReleaseSpec struct {
// and information about how they should be merged.
ValuesFrom []ValuesReference `json:"valuesFrom,omitempty"`
+ // Decryption holds decryption provider configuration for values.
+ // When set, the controller will attempt to decrypt composed values prior
+ // to performing Helm actions. The most common provider is SOPS.
+ // +optional
+ Decryption *Decryption `json:"decryption,omitempty"`
+
// Values holds the values for this Helm release.
// +optional
Values *apiextensionsv1.JSON `json:"values,omitempty"`
@@ -226,6 +232,26 @@ type Kustomize struct {
Images []kustomize.Image `json:"images,omitempty"`
}
+// Decryption describes how to decrypt values referenced by the HelmRelease.
+// The controller will use this configuration to attempt decryption of composed
+// values prior to performing Helm actions.
+type Decryption struct {
+ // Provider specifies the decryption provider (for example: "sops").
+ // +optional
+ Provider string `json:"provider,omitempty"`
+
+ // SecretRef refers to a Kubernetes Secret containing provider credentials
+ // when applicable (for example a GCP service account JSON for SOPS KMS).
+ // +optional
+ SecretRef *meta.LocalObjectReference `json:"secretRef,omitempty"`
+
+ // ServiceAccountName may be used to indicate a ServiceAccount whose
+ // credentials should be used for provider access. Controller support
+ // for this field depends on implementation.
+ // +optional
+ ServiceAccountName string `json:"serviceAccountName,omitempty"`
+}
+
// CommonMetadata defines the common labels and annotations.
type CommonMetadata struct {
// Annotations to be added to the object's metadata.
diff --git a/api/v2/zz_generated.deepcopy.go b/api/v2/zz_generated.deepcopy.go
index f371a2181..0c78e351f 100644
--- a/api/v2/zz_generated.deepcopy.go
+++ b/api/v2/zz_generated.deepcopy.go
@@ -87,6 +87,26 @@ func (in *CrossNamespaceSourceReference) DeepCopy() *CrossNamespaceSourceReferen
return out
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *Decryption) DeepCopyInto(out *Decryption) {
+ *out = *in
+ if in.SecretRef != nil {
+ in, out := &in.SecretRef, &out.SecretRef
+ *out = new(meta.LocalObjectReference)
+ **out = **in
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Decryption.
+func (in *Decryption) DeepCopy() *Decryption {
+ if in == nil {
+ return nil
+ }
+ out := new(Decryption)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *DependencyReference) DeepCopyInto(out *DependencyReference) {
*out = *in
@@ -373,6 +393,11 @@ func (in *HelmReleaseSpec) DeepCopyInto(out *HelmReleaseSpec) {
*out = make([]ValuesReference, len(*in))
copy(*out, *in)
}
+ if in.Decryption != nil {
+ in, out := &in.Decryption, &out.Decryption
+ *out = new(Decryption)
+ (*in).DeepCopyInto(*out)
+ }
if in.Values != nil {
in, out := &in.Values, &out.Values
*out = new(apiextensionsv1.JSON)
diff --git a/config/crd/bases/helm.toolkit.fluxcd.io_helmreleases.yaml b/config/crd/bases/helm.toolkit.fluxcd.io_helmreleases.yaml
index bf06f7d30..fd4d08e20 100644
--- a/config/crd/bases/helm.toolkit.fluxcd.io_helmreleases.yaml
+++ b/config/crd/bases/helm.toolkit.fluxcd.io_helmreleases.yaml
@@ -238,6 +238,34 @@ spec:
description: Labels to be added to the object's metadata.
type: object
type: object
+ decryption:
+ description: |-
+ Decryption holds decryption provider configuration for values.
+ When set, the controller will attempt to decrypt composed values prior
+ to performing Helm actions. The most common provider is SOPS.
+ properties:
+ provider:
+ description: 'Provider specifies the decryption provider (for
+ example: "sops").'
+ type: string
+ secretRef:
+ description: |-
+ SecretRef refers to a Kubernetes Secret containing provider credentials
+ when applicable (for example a GCP service account JSON for SOPS KMS).
+ properties:
+ name:
+ description: Name of the referent.
+ type: string
+ required:
+ - name
+ type: object
+ serviceAccountName:
+ description: |-
+ ServiceAccountName may be used to indicate a ServiceAccount whose
+ credentials should be used for provider access. Controller support
+ for this field depends on implementation.
+ type: string
+ type: object
dependsOn:
description: |-
DependsOn may contain a DependencyReference slice with
diff --git a/docs/api/v2/helm.md b/docs/api/v2/helm.md
index 639745c4f..e89c971e0 100644
--- a/docs/api/v2/helm.md
+++ b/docs/api/v2/helm.md
@@ -362,6 +362,22 @@ and information about how they should be merged.
+decryption
+
+
+Decryption
+
+
+ |
+
+(Optional)
+ Decryption holds decryption provider configuration for values.
+When set, the controller will attempt to decrypt composed values prior
+to performing Helm actions. The most common provider is SOPS.
+ |
+
+
+
values
@@ -669,6 +685,70 @@ resource object that contains the reference.
+
+
+(Appears on:
+HelmReleaseSpec)
+
+Decryption describes how to decrypt values referenced by the HelmRelease.
+The controller will use this configuration to attempt decryption of composed
+values prior to performing Helm actions.
+
@@ -1548,6 +1628,22 @@ and information about how they should be merged.
|
+decryption
+
+
+Decryption
+
+
+ |
+
+(Optional)
+ Decryption holds decryption provider configuration for values.
+When set, the controller will attempt to decrypt composed values prior
+to performing Helm actions. The most common provider is SOPS.
+ |
+
+
+
values
diff --git a/go.mod b/go.mod
index 36f63a023..83327310f 100644
--- a/go.mod
+++ b/go.mod
@@ -44,6 +44,7 @@ require (
github.com/wI2L/jsondiff v0.7.0
go.uber.org/zap v1.27.1
golang.org/x/text v0.34.0
+ gopkg.in/yaml.v3 v3.0.1
helm.sh/helm/v4 v4.1.3
k8s.io/api v0.35.2
k8s.io/apiextensions-apiserver v0.35.2
@@ -223,7 +224,6 @@ require (
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
- gopkg.in/yaml.v3 v3.0.1 // indirect
gotest.tools/v3 v3.4.0 // indirect
k8s.io/apiserver v0.35.2 // indirect
k8s.io/component-base v0.35.2 // indirect
diff --git a/internal/controller/decrypt.go b/internal/controller/decrypt.go
new file mode 100644
index 000000000..a9ba03fdb
--- /dev/null
+++ b/internal/controller/decrypt.go
@@ -0,0 +1,82 @@
+/*
+Copyright 2023 The Flux authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package controller
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "os/exec"
+
+ "gopkg.in/yaml.v3"
+ ctrl "sigs.k8s.io/controller-runtime"
+
+ v2 "github.com/fluxcd/helm-controller/api/v2"
+)
+
+// runSops performs SOPS decryption of the provided YAML bytes.
+// Overridable for unit testing.
+var runSops = func(ctx context.Context, in []byte) ([]byte, error) {
+ cmd := exec.CommandContext(ctx, "sops", "--decrypt", "--output-type", "yaml", "-")
+ cmd.Stdin = bytes.NewReader(in)
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ return nil, fmt.Errorf("sops decrypt failed: %w: %s", err, string(out))
+ }
+ return out, nil
+}
+
+// decryptValues decrypts composed Helm values when `obj.Spec.Decryption` is set.
+// It marshals `values` to YAML, pipes through `sops --decrypt` and unmarshals the result.
+// For production, prefer reusing fluxcd/pkg/kustomize/kustomize-controller decryption logic.
+func (r *HelmReleaseReconciler) decryptValues(ctx context.Context, obj *v2.HelmRelease, values map[string]any) (map[string]any, error) {
+ log := ctrl.LoggerFrom(ctx)
+
+ if values == nil {
+ return nil, fmt.Errorf("values are nil")
+ }
+
+ in, err := yaml.Marshal(values)
+ if err != nil {
+ return nil, fmt.Errorf("marshal values: %w", err)
+ }
+
+ out, err := runSops(ctx, in)
+ if err != nil {
+ // avoid exposing secret contents
+ log.Info("sops decryption failed")
+ return nil, fmt.Errorf("decryption failed: %w", err)
+ }
+
+ var dec map[string]any
+ if err := yaml.Unmarshal(out, &dec); err != nil {
+ return nil, fmt.Errorf("unmarshal decrypted values: %w", err)
+ }
+
+ return dec, nil
+}
+
+// DecryptValues is an exported wrapper for testing and external usage that
+// delegates to the reconciler's decryptValues method.
+func DecryptValues(ctx context.Context, r *HelmReleaseReconciler, obj *v2.HelmRelease, values map[string]any) (map[string]any, error) {
+ return r.decryptValues(ctx, obj, values)
+}
+
+// SetSopsRunner allows tests to override the sops runner implementation.
+func SetSopsRunner(runner func(ctx context.Context, in []byte) ([]byte, error)) {
+ runSops = runner
+}
diff --git a/internal/controller/decrypt_test.go b/internal/controller/decrypt_test.go
new file mode 100644
index 000000000..b5266f095
--- /dev/null
+++ b/internal/controller/decrypt_test.go
@@ -0,0 +1,44 @@
+package controller_test
+
+import (
+ "context"
+ "reflect"
+ "testing"
+
+ v2 "github.com/fluxcd/helm-controller/api/v2"
+ ctrlpkg "github.com/fluxcd/helm-controller/internal/controller"
+)
+
+func TestDecryptValues_Success(t *testing.T) {
+ // Override runSops to return a simple decrypted YAML
+ ctrlpkg.SetSopsRunner(func(ctx context.Context, in []byte) ([]byte, error) {
+ return []byte("alpha: 1\nbeta:\n nested: xyz\n"), nil
+ })
+
+ r := &ctrlpkg.HelmReleaseReconciler{}
+ obj := &v2.HelmRelease{
+ Spec: v2.HelmReleaseSpec{
+ Decryption: &v2.Decryption{}, // presence is enough for this test
+ },
+ }
+
+ input := map[string]any{
+ "alpha": 123,
+ }
+
+ got, err := ctrlpkg.DecryptValues(context.Background(), r, obj, input)
+ if err != nil {
+ t.Fatalf("DecryptValues returned error: %v", err)
+ }
+
+ want := map[string]any{
+ "alpha": int(1),
+ "beta": map[string]any{
+ "nested": "xyz",
+ },
+ }
+
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("unexpected decrypted values.\nwant: %#v\ngot: %#v", want, got)
+ }
+}
diff --git a/internal/controller/helmrelease_controller.go b/internal/controller/helmrelease_controller.go
index e0c3315da..98f3170f4 100644
--- a/internal/controller/helmrelease_controller.go
+++ b/internal/controller/helmrelease_controller.go
@@ -325,6 +325,21 @@ func (r *HelmReleaseReconciler) reconcileRelease(ctx context.Context,
conditions.MarkUnknown(obj, meta.ReadyCondition, meta.ProgressingReason, "reconciliation in progress")
}
+ // If decryption configured, decrypt composed values before loading chart.
+ if obj.Spec.Decryption != nil {
+ if obj.Spec.Decryption.Provider == "sops" || obj.Spec.Decryption.Provider == "" {
+ values, err = r.decryptValues(ctx, obj, values)
+ if err != nil {
+ conditions.MarkFalse(obj, meta.ReadyCondition, "DecryptionFailed", "%s", err)
+ r.Eventf(obj, corev1.EventTypeWarning, "DecryptionFailed", err.Error())
+ return ctrl.Result{}, err
+ }
+ } else {
+ conditions.MarkFalse(obj, meta.ReadyCondition, "DecryptionError", "unsupported provider %s", obj.Spec.Decryption.Provider)
+ return ctrl.Result{}, fmt.Errorf("unsupported decryption provider %q", obj.Spec.Decryption.Provider)
+ }
+ }
+
// Load chart from artifact.
loadedChart, err := loader.SecureLoadChartFromURL(loader.NewRetryableHTTPClient(ctx, r.ArtifactFetchRetries, r.ArtifactFetchTimeout), source.GetArtifact().URL, source.GetArtifact().Digest)
if err != nil {
diff --git a/internal/controller_decrypt_test/decrypt_test.go b/internal/controller_decrypt_test/decrypt_test.go
new file mode 100644
index 000000000..8bac19d58
--- /dev/null
+++ b/internal/controller_decrypt_test/decrypt_test.go
@@ -0,0 +1,44 @@
+package controller_decrypt_test
+
+import (
+ "context"
+ "reflect"
+ "testing"
+
+ v2 "github.com/fluxcd/helm-controller/api/v2"
+ ctrlpkg "github.com/fluxcd/helm-controller/internal/controller"
+)
+
+func TestDecryptValues_Success(t *testing.T) {
+ // Override runSops to return a simple decrypted YAML
+ ctrlpkg.SetSopsRunner(func(ctx context.Context, in []byte) ([]byte, error) {
+ return []byte("alpha: 1\nbeta:\n nested: xyz\n"), nil
+ })
+
+ r := &ctrlpkg.HelmReleaseReconciler{}
+ obj := &v2.HelmRelease{
+ Spec: v2.HelmReleaseSpec{
+ Decryption: &v2.Decryption{}, // presence is enough for this test
+ },
+ }
+
+ input := map[string]any{
+ "alpha": 123,
+ }
+
+ got, err := ctrlpkg.DecryptValues(context.Background(), r, obj, input)
+ if err != nil {
+ t.Fatalf("DecryptValues returned error: %v", err)
+ }
+
+ want := map[string]any{
+ "alpha": int(1),
+ "beta": map[string]any{
+ "nested": "xyz",
+ },
+ }
+
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("unexpected decrypted values.\nwant: %#v\ngot: %#v", want, got)
+ }
+}
diff --git a/tests/integration/sops_decryption_test.go b/tests/integration/sops_decryption_test.go
new file mode 100644
index 000000000..ad5127c4e
--- /dev/null
+++ b/tests/integration/sops_decryption_test.go
@@ -0,0 +1,25 @@
+package integration
+
+import (
+ "context"
+ "testing"
+)
+
+// NOTE: Integration test skeleton. To run:
+// - Build controller image containing `sops` and push to registry.
+// - Deploy controller image to the Kind/cluster used for tests.
+// - Create Kubernetes Secret(s) / ServiceAccount per your KMS provider.
+// - Create a HelmRelease that references a sops-encrypted values file and sets `spec.decryption`.
+//
+// This test verifies that a HelmRelease with sops-encrypted values is successfully applied.
+func TestSOPSDecryptionIntegration(t *testing.T) {
+ t.Skip("integration test skeleton: implement cluster setup and controller image with sops")
+ ctx := context.Background()
+
+ _ = ctx
+ // Steps:
+ // 1. Create secret(s) needed for sops/KMS access.
+ // 2. Apply HelmRelease CR with `spec.decryption: { provider: sops, secretRef: { name: ... } }`.
+ // 3. Wait for HelmRelease Ready condition or release presence.
+ // 4. Assert that the applied resources contain expected plaintext values.
+}
|