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.

+

Decryption +

+

+(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.

+
+
+ + + + + + + + + + + + + + + + + + + + + +
FieldDescription
+provider
+ +string + +
+(Optional) +

Provider specifies the decryption provider (for example: “sops”).

+
+secretRef
+ + +github.com/fluxcd/pkg/apis/meta.LocalObjectReference + + +
+(Optional) +

SecretRef refers to a Kubernetes Secret containing provider credentials +when applicable (for example a GCP service account JSON for SOPS KMS).

+
+serviceAccountName
+ +string + +
+(Optional) +

ServiceAccountName may be used to indicate a ServiceAccount whose +credentials should be used for provider access. Controller support +for this field depends on implementation.

+
+
+

DependencyReference

@@ -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. +}