Add resources attr (#1693)

* Add resources attr

* Remove unnecessary comments

* Fix unknown value error

* Add resources to upgradestate map

* Remove annotations

* remove comments

* Fix inconsitent results error

* Recompute metadata when resources map changes

* Fix import test

* Fix failing test manifestUnknownValues

* Add missing copywrite headers

* Add changelog entry

* Add changelog entry

* Update docs

---------

Co-authored-by: John Houston <jhouston@hashicorp.com>
This commit is contained in:
Jaylon McShan 2025-09-22 18:10:26 -05:00 committed by GitHub
parent b278d36af3
commit c8c5571ee1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 755 additions and 4 deletions

3
.changelog/1693.txt Normal file
View file

@ -0,0 +1,3 @@
```release-note:feature
Add `resources` attribute to manifest experimental feature
```

View file

@ -68,6 +68,7 @@ A Chart is a Helm package. It contains all of the resource definitions necessary
- `id` (String) The ID of this resource.
- `manifest` (String) The rendered manifest as JSON.
- `resources` (Map of String) Rendered manifests as JSON.
- `metadata` (List of Object) Status of the deployed release. (see [below for nested schema](#nestedatt--metadata))
- `status` (String) Status of the release.

3
go.mod
View file

@ -57,6 +57,7 @@ require (
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
github.com/evanphx/json-patch v5.9.11+incompatible // indirect
github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect
github.com/fatih/camelcase v1.0.0 // indirect
github.com/fatih/color v1.16.0 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/ghodss/yaml v1.0.0 // indirect
@ -98,6 +99,7 @@ require (
github.com/huandu/xstrings v1.5.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jmoiron/sqlx v1.4.0 // indirect
github.com/jonboulle/clockwork v0.5.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.0 // indirect
@ -168,6 +170,7 @@ require (
k8s.io/apiserver v0.33.2 // indirect
k8s.io/cli-runtime v0.33.2 // indirect
k8s.io/component-base v0.33.2 // indirect
k8s.io/component-helpers v0.33.2 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect
k8s.io/kubectl v0.33.2 // indirect

6
go.sum
View file

@ -97,6 +97,8 @@ github.com/evanphx/json-patch v5.9.11+incompatible h1:ixHHqfcGvxhWkniF1tWxBHA0yb
github.com/evanphx/json-patch v5.9.11+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f h1:Wl78ApPPB2Wvf/TIe2xdyJxTlb6obmF18d8QdkxNDu4=
github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f/go.mod h1:OSYXu++VVOHnXeitef/D8n/6y4QV8uLHSFXX4NeXMGc=
github.com/fatih/camelcase v1.0.0 h1:hxNvNX/xYBp0ovncs8WyWZrOrpBNub/JfaMvbURyft8=
github.com/fatih/camelcase v1.0.0/go.mod h1:yN2Sb0lFhZJUdVvtELVWefmrXpuZESvPmqwoZc+/fpc=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
@ -249,6 +251,8 @@ github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgf
github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
@ -594,6 +598,8 @@ k8s.io/client-go v0.33.2 h1:z8CIcc0P581x/J1ZYf4CNzRKxRvQAwoAolYPbtQes+E=
k8s.io/client-go v0.33.2/go.mod h1:9mCgT4wROvL948w6f6ArJNb7yQd7QsvqavDeZHvNmHo=
k8s.io/component-base v0.33.2 h1:sCCsn9s/dG3ZrQTX/Us0/Sx2R0G5kwa0wbZFYoVp/+0=
k8s.io/component-base v0.33.2/go.mod h1:/41uw9wKzuelhN+u+/C59ixxf4tYQKW7p32ddkYNe2k=
k8s.io/component-helpers v0.33.2 h1:AjCtYzst11NV8ensxV/2LEEXRwctqS7Bs44bje9Qcnw=
k8s.io/component-helpers v0.33.2/go.mod h1:PsPpiCk74n8pGWp1d6kjK/iSKBTyQfIacv02BNkMenU=
k8s.io/helm v2.17.0+incompatible h1:Bpn6o1wKLYqKM3+Osh8e+1/K2g/GsQJ4F4yNF2+deao=
k8s.io/helm v2.17.0+incompatible/go.mod h1:LZzlS4LQBHfciFOurYBFkCMTaZ0D1l+p0teMg7TSULI=
k8s.io/klog v1.0.0 h1:Pt+yjF5aB1xDSVbau4VsWe+dQNzA0qv1LlXdC2dF6Q8=

312
helm/kube_resources.go Normal file
View file

@ -0,0 +1,312 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package helm
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/pkg/errors"
"helm.sh/helm/v3/pkg/action"
"helm.sh/helm/v3/pkg/kube"
"helm.sh/helm/v3/pkg/release"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
apimeta "k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/managedfields"
"k8s.io/cli-runtime/pkg/genericiooptions"
"k8s.io/cli-runtime/pkg/resource"
"k8s.io/client-go/discovery"
"k8s.io/kube-openapi/pkg/util/proto"
"k8s.io/kubectl/pkg/cmd/diff"
"k8s.io/kubectl/pkg/scheme"
"sigs.k8s.io/structured-merge-diff/v4/fieldpath"
)
// getKubeClient returns the underlying *kube.Client from an action.Configuration.
func getKubeClient(actionConfig *action.Configuration) (*kube.Client, error) {
kc, ok := actionConfig.KubeClient.(*kube.Client)
if !ok {
return nil, errors.Errorf("client is not a *kube.Client")
}
return kc, nil
}
// regenerateGVKParser builds the parser from the raw OpenAPI schema.
func regenerateGVKParser(dc discovery.DiscoveryInterface) (*managedfields.GvkParser, error) {
doc, err := dc.OpenAPISchema()
if err != nil {
return nil, err
}
models, err := proto.NewOpenAPIData(doc)
if err != nil {
return nil, err
}
return managedfields.NewGVKParser(models, false)
}
// removeUnmanagedFields strips fields managed by kube-controller-manager or subresources.
func removeUnmanagedFields(parser *managedfields.GvkParser, obj runtime.Object, gvk schema.GroupVersionKind) error {
parseableType := parser.Type(gvk)
if parseableType == nil {
return errors.Errorf("no parseable type found for %s", gvk.String())
}
typedObj, err := parseableType.FromStructured(obj)
if err != nil {
return err
}
accessor, err := apimeta.Accessor(obj)
if err != nil {
return err
}
objManagedFields := accessor.GetManagedFields()
fieldSet := &fieldpath.Set{}
for _, mf := range objManagedFields {
if mf.Manager == "kube-controller-manager" || mf.Subresource != "" {
fs := &fieldpath.Set{}
if err := fs.FromJSON(bytes.NewReader(mf.FieldsV1.Raw)); err != nil {
return err
}
fieldSet = fieldSet.Union(fs)
}
}
u := typedObj.RemoveItems(fieldSet).AsValue().Unstructured()
m, ok := u.(map[string]interface{})
if !ok {
return errors.Errorf("unexpected type %T", u)
}
return runtime.DefaultUnstructuredConverter.FromUnstructured(m, obj)
}
// mapRuntimeObjects converts runtime.Objects to JSON with unmanaged fields removed and sensitive values redacted.
func mapRuntimeObjects(ctx context.Context, kc *kube.Client, objects []runtime.Object) (map[string]string, diag.Diagnostics) {
var diags diag.Diagnostics
clientSet, err := kc.Factory.KubernetesClientSet()
if err != nil {
diags.AddError("Client Error", err.Error())
return nil, diags
}
parser, err := regenerateGVKParser(clientSet.Discovery())
if err != nil {
diags.AddError("Parser Error", err.Error())
return nil, diags
}
mappedObjects := make(map[string]string)
for _, obj := range objects {
gvk := obj.GetObjectKind().GroupVersionKind()
if gvk.Kind == "Secret" {
secret := &corev1.Secret{}
if err := scheme.Scheme.Convert(obj, secret, nil); err != nil {
diags.AddError("Secret Conversion Error", err.Error())
return nil, diags
}
redactSecretData(secret)
obj = secret
}
accessor, err := apimeta.Accessor(obj)
if err != nil {
diags.AddError("Object Access Error", err.Error())
return nil, diags
}
key := fmt.Sprintf("%s/%s/%s/%s",
strings.ToLower(gvk.GroupKind().String()),
gvk.Version,
accessor.GetNamespace(),
accessor.GetName(),
)
if err := removeUnmanagedFields(parser, obj, gvk); err != nil {
diags.AddError("Field Removal Error", err.Error())
return nil, diags
}
accessor.SetUID(types.UID(""))
accessor.SetCreationTimestamp(metav1.Time{})
accessor.SetResourceVersion("")
accessor.SetManagedFields(nil)
if ta, err := apimeta.TypeAccessor(obj); err == nil {
if ta.GetKind() == "" {
ta.SetKind(gvk.Kind)
}
if ta.GetAPIVersion() == "" {
ta.SetAPIVersion(gvk.GroupVersion().String())
}
}
umap, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj)
if err != nil {
diags.AddError("Unstructured Conversion Error", err.Error())
return nil, diags
}
normalizeK8sObject(umap)
// Marshal back to JSON for the state
objJSON, err := json.Marshal(umap)
if err != nil {
diags.AddError("Marshal Error", err.Error())
return nil, diags
}
mappedObjects[key] = string(objJSON)
tflog.Debug(ctx, "Mapped runtime object", map[string]interface{}{"key": key})
}
return mappedObjects, diags
}
func mapResources(ctx context.Context, actionConfig *action.Configuration, r *release.Release, f func(*resource.Info) (runtime.Object, error)) (map[string]string, diag.Diagnostics) {
var diags diag.Diagnostics
resources, err := actionConfig.KubeClient.Build(bytes.NewBufferString(r.Manifest), false)
if err != nil {
diags.AddError("Build Error", err.Error())
return nil, diags
}
var objects []runtime.Object
err = resources.Visit(func(i *resource.Info, err error) error {
if err != nil {
return err
}
obj, err := f(i)
if apierrors.IsNotFound(err) {
return nil
}
if err != nil {
return err
}
objects = append(objects, obj)
return nil
})
if err != nil {
diags.AddError("Visit Error", err.Error())
return nil, diags
}
kc, err := getKubeClient(actionConfig)
if err != nil {
diags.AddError("Client Error", err.Error())
return nil, diags
}
return mapRuntimeObjects(ctx, kc, objects)
}
// getLiveResources fetches the live cluster resources of a Helm release.
func getLiveResources(ctx context.Context, r *release.Release, m *Meta) (map[string]string, diag.Diagnostics) {
var diags diag.Diagnostics
actionConfig, err := m.GetHelmConfiguration(ctx, r.Namespace)
if err != nil {
diags.AddError("Helm Config Error", err.Error())
return nil, diags
}
kc, err := getKubeClient(actionConfig)
if err != nil {
diags.AddError("Kube Client Error", err.Error())
return nil, diags
}
rawResources, resDiags := mapResources(ctx, actionConfig, r, func(i *resource.Info) (runtime.Object, error) {
gvk := i.Object.GetObjectKind().GroupVersionKind()
return kc.Factory.NewBuilder().
Unstructured().
NamespaceParam(i.Namespace).DefaultNamespace().
ResourceNames(gvk.GroupKind().String(), i.Name).
Flatten().
Do().
Object()
})
diags.Append(resDiags...)
if resDiags.HasError() {
return rawResources, diags
}
cleaned := make(map[string]string, len(rawResources))
for k, v := range rawResources {
var obj map[string]any
if err := json.Unmarshal([]byte(v), &obj); err != nil {
cleaned[k] = v
continue
}
normalizeK8sObject(obj)
if b, err := json.Marshal(obj); err == nil {
cleaned[k] = string(b)
} else {
cleaned[k] = v
}
}
return cleaned, diags
}
func getDryRunResources(ctx context.Context, r *release.Release, m *Meta) (map[string]string, diag.Diagnostics) {
var diags diag.Diagnostics
actionConfig, err := m.GetHelmConfiguration(ctx, r.Namespace)
if err != nil {
diags.AddError("Helm Config Error", err.Error())
return nil, diags
}
ioStreams := genericiooptions.IOStreams{
In: os.Stdin,
Out: os.Stdout,
ErrOut: os.Stderr,
}
fieldManager := "terraform-provider-helm"
if os.Args[0] != "" {
fieldManager = filepath.Base(os.Args[0])
}
rawResources, resDiags := mapResources(ctx, actionConfig, r, func(i *resource.Info) (runtime.Object, error) {
info := &diff.InfoObject{
LocalObj: i.Object,
Info: i,
Encoder: scheme.DefaultJSONEncoder(),
Force: false,
ServerSideApply: true,
FieldManager: fieldManager,
ForceConflicts: true,
IOStreams: ioStreams,
}
return info.Merged()
})
diags.Append(resDiags...)
if resDiags.HasError() {
return rawResources, diags
}
cleaned := make(map[string]string, len(rawResources))
for k, v := range rawResources {
var obj map[string]any
if err := json.Unmarshal([]byte(v), &obj); err != nil {
cleaned[k] = v
continue
}
normalizeK8sObject(obj)
if b, err := json.Marshal(obj); err == nil {
cleaned[k] = string(b)
} else {
cleaned[k] = v
}
}
return cleaned, diags
}

View file

@ -93,3 +93,10 @@ func redactSensitiveValues(text string, sensitiveValues map[string]string) strin
return masked
}
func redactSecretData(secret *corev1.Secret) {
for k, v := range secret.Data {
h := hashSensitiveValue(string(v))
secret.Data[k] = []byte(h)
}
}

View file

@ -88,6 +88,7 @@ type HelmReleaseModel struct {
Namespace types.String `tfsdk:"namespace"`
PassCredentials types.Bool `tfsdk:"pass_credentials"`
PostRender *PostRenderModel `tfsdk:"postrender"`
Resources types.Map `tfsdk:"resources"`
RecreatePods types.Bool `tfsdk:"recreate_pods"`
Replace types.Bool `tfsdk:"replace"`
RenderSubchartNotes types.Bool `tfsdk:"render_subchart_notes"`
@ -489,6 +490,11 @@ func (r *HelmRelease) Schema(ctx context.Context, req resource.SchemaRequest, re
Description: "When upgrading, reuse the last release's values and merge in any overrides. If 'reset_values' is specified, this is ignored",
Default: booldefault.StaticBool(defaultAttributes["reuse_values"].(bool)),
},
"resources": schema.MapAttribute{
Description: "The kubernetes resources created by this release.",
Computed: true,
ElementType: types.StringType,
},
"skip_crds": schema.BoolAttribute{
Optional: true,
Computed: true,
@ -1615,8 +1621,9 @@ func versionsEqual(a, b string) bool {
func setReleaseAttributes(ctx context.Context, state *HelmReleaseModel, identity *tfsdk.ResourceIdentity, r *release.Release, meta *Meta) diag.Diagnostics {
var diags diag.Diagnostics
// Update state with attributes from the helm release
state.Resources = types.MapNull(types.StringType)
state.Manifest = types.StringNull()
state.Name = types.StringValue(r.Name)
version := r.Chart.Metadata.Version
if !versionsEqual(version, state.Version.ValueString()) {
@ -1667,6 +1674,22 @@ func setReleaseAttributes(ctx context.Context, state *HelmReleaseModel, identity
sensitiveValues := extractSensitiveValues(state)
manifest := redactSensitiveValues(string(jsonManifest), sensitiveValues)
state.Manifest = types.StringValue(manifest)
resources, resDiags := getLiveResources(ctx, r, meta)
diags.Append(resDiags...)
if !resDiags.HasError() {
resMap, resConvDiags := mapToTerraformStringMap(ctx, resources)
diags.Append(resConvDiags...)
if resConvDiags.HasError() {
state.Resources = types.MapValueMust(types.StringType, map[string]attr.Value{})
} else {
state.Resources = resMap
}
} else {
state.Resources = types.MapValueMust(types.StringType, map[string]attr.Value{})
}
}
// NOTE Don't retrieve values if write-only is being used.
@ -1709,6 +1732,14 @@ func setReleaseAttributes(ctx context.Context, state *HelmReleaseModel, identity
return diags
}
func mapToTerraformStringMap(ctx context.Context, m map[string]string) (types.Map, diag.Diagnostics) {
valueMap := make(map[string]attr.Value)
for k, v := range m {
valueMap[k] = types.StringValue(v)
}
return types.MapValue(types.StringType, valueMap)
}
func metadataAttrTypes() map[string]attr.Type {
return map[string]attr.Type{
"name": types.StringType,
@ -1956,8 +1987,12 @@ func (r *HelmRelease) ModifyPlan(ctx context.Context, req resource.ModifyPlanReq
// Check if all necessary values are known
if valuesUnknown(plan) {
tflog.Debug(ctx, "not all values are known, skipping dry run to render manifest")
plan.Manifest = types.StringNull()
plan.Version = types.StringNull()
plan.Manifest = types.StringUnknown()
plan.Resources = types.MapUnknown(types.StringType)
if config.Version.IsNull() {
plan.Version = types.StringUnknown()
}
resp.Plan.Set(ctx, &plan)
return
}
@ -2019,6 +2054,7 @@ func (r *HelmRelease) ModifyPlan(ctx context.Context, req resource.ModifyPlanReq
if strings.Contains(err.Error(), "Kubernetes cluster unreachable") {
resp.Diagnostics.AddError("cluster was unreachable at create time, marking manifest as computed", err.Error())
plan.Manifest = types.StringNull()
resp.Plan.Set(ctx, &plan)
return
}
resp.Diagnostics.AddError("Error performing dry run install", err.Error())
@ -2045,6 +2081,14 @@ func (r *HelmRelease) ModifyPlan(ctx context.Context, req resource.ModifyPlanReq
}
manifest := redactSensitiveValues(string(jsonManifest), valuesMap)
plan.Manifest = types.StringValue(manifest)
resources, resDiags := getDryRunResources(ctx, dry, meta)
resp.Diagnostics.Append(resDiags...)
if resp.Diagnostics.HasError() {
return
}
plan.Resources, diags = types.MapValueFrom(ctx, types.StringType, resources)
resp.Diagnostics.Append(diags...)
resp.Plan.Set(ctx, &plan)
return
}
@ -2054,6 +2098,8 @@ func (r *HelmRelease) ModifyPlan(ctx context.Context, req resource.ModifyPlanReq
plan.Version = types.StringValue(chart.Metadata.Version)
}
plan.Manifest = types.StringNull()
plan.Resources = types.MapNull(types.StringType)
resp.Plan.Set(ctx, &plan)
return
} else if err != nil {
resp.Diagnostics.AddError("Error retrieving old release for a diff", err.Error())
@ -2095,6 +2141,8 @@ func (r *HelmRelease) ModifyPlan(ctx context.Context, req resource.ModifyPlanReq
}
plan.Version = types.StringNull()
plan.Manifest = types.StringNull()
plan.Resources = types.MapNull(types.StringType)
resp.Plan.Set(ctx, &plan)
return
} else if err != nil {
resp.Diagnostics.AddError("Error running dry run for a diff", err.Error())
@ -2121,9 +2169,26 @@ func (r *HelmRelease) ModifyPlan(ctx context.Context, req resource.ModifyPlanReq
}
manifest := redactSensitiveValues(string(jsonManifest), valuesMap)
plan.Manifest = types.StringValue(manifest)
resources, resDiags := getDryRunResources(ctx, dry, meta)
resp.Diagnostics.Append(resDiags...)
if resp.Diagnostics.HasError() {
return
}
plan.Resources, diags = types.MapValueFrom(ctx, types.StringType, resources)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
tflog.Debug(ctx, fmt.Sprintf("%s set manifest: %s", logID, jsonManifest))
if !state.Resources.Equal(plan.Resources) {
plan.Metadata = types.ObjectUnknown(metadataAttrTypes())
}
} else {
plan.Manifest = types.StringNull()
plan.Resources = types.MapNull(types.StringType)
}
tflog.Debug(ctx, fmt.Sprintf("%s Done", logID))
@ -2394,5 +2459,139 @@ func valuesUnknown(plan HelmReleaseModel) bool {
if plan.SetSensitive.IsUnknown() {
return true
}
sensitive := []setResourceModel{}
plan.SetSensitive.ElementsAs(context.Background(), &sensitive, false)
for _, s := range sensitive {
if s.Value.IsUnknown() {
return true
}
}
set := []setResourceModel{}
plan.Set.ElementsAs(context.Background(), &set, false)
for _, s := range set {
if s.Value.IsUnknown() {
return true
}
}
setList := []setResourceModel{}
plan.Set.ElementsAs(context.Background(), &setList, false)
for _, s := range setList {
if s.Value.IsUnknown() {
return true
}
}
return false
}
func isInternalAnno(key string) bool {
u, err := url.Parse("//" + key)
if err != nil {
return false
}
host := u.Hostname()
if host == "app.kubernetes.io" || host == "service.beta.kubernetes.io" {
return false
}
if strings.HasSuffix(host, "kubernetes.io") || strings.HasSuffix(host, "k8s.io") {
return true
}
if strings.Contains(key, "deprecated.daemonset.template.generation") {
return true
}
return false
}
func stripServerSideAnnotations(obj map[string]any) {
md, _ := obj["metadata"].(map[string]any)
if md == nil {
return
}
ann, _ := md["annotations"].(map[string]any)
if ann == nil {
return
}
for k := range ann {
if isInternalAnno(k) {
delete(ann, k)
}
}
if len(ann) == 0 {
delete(md, "annotations")
}
}
func stripHelmMetaAnnotations(obj map[string]any) {
md, _ := obj["metadata"].(map[string]any)
if md == nil {
return
}
ann, _ := md["annotations"].(map[string]any)
if ann == nil {
return
}
for k := range ann {
if strings.HasPrefix(k, "meta.helm.sh/") {
delete(ann, k)
}
}
if len(ann) == 0 {
delete(md, "annotations")
}
}
func stripVolatileFields(obj map[string]any) {
if md, _ := obj["metadata"].(map[string]any); md != nil {
delete(md, "managedFields")
delete(md, "resourceVersion")
delete(md, "uid")
delete(md, "creationTimestamp")
}
// Service fields assigned by API server
if kind, _ := obj["kind"].(string); kind == "Service" {
if spec, _ := obj["spec"].(map[string]any); spec != nil {
delete(spec, "clusterIP")
delete(spec, "clusterIPs")
}
}
}
func stripSecretManagedByLabel(obj map[string]any) {
if kind, _ := obj["kind"].(string); kind != "Secret" {
return
}
md, _ := obj["metadata"].(map[string]any)
if md == nil {
return
}
labels, _ := md["labels"].(map[string]any)
if labels == nil {
return
}
delete(labels, "app.kubernetes.io/managed-by")
if len(labels) == 0 {
delete(md, "labels")
}
}
func normalizeStatus(obj map[string]any) {
kind, _ := obj["kind"].(string)
if kind == "Deployment" {
obj["status"] = nil
return
}
delete(obj, "status")
}
func normalizeK8sObject(obj map[string]any) {
stripServerSideAnnotations(obj)
stripHelmMetaAnnotations(obj)
stripVolatileFields(obj)
stripSecretManagedByLabel(obj)
normalizeStatus(obj)
}

View file

@ -244,6 +244,7 @@ func (r *HelmRelease) buildUpgradeStateMap(_ context.Context) map[int64]resource
"repository_password": tftypes.String,
"repository_username": tftypes.String,
"reset_values": tftypes.Bool,
"resources": tftypes.Map{ElementType: tftypes.String},
"reuse_values": tftypes.Bool,
"skip_crds": tftypes.Bool,
"set_wo_revision": tftypes.Number,
@ -270,6 +271,7 @@ func (r *HelmRelease) buildUpgradeStateMap(_ context.Context) map[int64]resource
),
"take_ownership": tftypes.NewValue(tftypes.Bool, false),
"set_wo_revision": tftypes.NewValue(tftypes.Number, float64(1)),
"resources": tftypes.NewValue(tftypes.Map{ElementType: tftypes.String}, map[string]tftypes.Value{}),
"atomic": oldState["atomic"],
"chart": oldState["chart"],
"cleanup_on_fail": oldState["cleanup_on_fail"],

View file

@ -4,7 +4,9 @@
package helm
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
"os"
@ -32,10 +34,13 @@ import (
"helm.sh/helm/v3/pkg/release"
"helm.sh/helm/v3/pkg/releaseutil"
"helm.sh/helm/v3/pkg/repo"
appsv1 "k8s.io/api/apps/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
runtimeresource "k8s.io/cli-runtime/pkg/resource"
)
func TestAccResourceRelease_basic(t *testing.T) {
@ -2719,3 +2724,216 @@ func testAccHelmReleaseConfigSetLiteral(resource, ns, name, version string) stri
}
`, resource, name, ns, testRepositoryURL, version)
}
// getTestKubeClient returns a Helm kube client for the given namespace.
func getTestKubeClientPF(t *testing.T, namespace string) *kube.Client {
t.Helper()
kubeconfig := os.Getenv("KUBE_CONFIG_PATH")
if kubeconfig == "" {
t.Fatal("KUBE_CONFIG_PATH must be set for acceptance tests")
}
actionConfig := &action.Configuration{}
if err := actionConfig.Init(
kube.GetConfig(kubeconfig, "", namespace),
namespace,
os.Getenv("HELM_DRIVER"),
t.Logf,
); err != nil {
t.Fatalf("init Helm action configuration: %v", err)
}
client, err := getKubeClient(actionConfig)
if err != nil {
t.Fatalf("get kube client: %v", err)
}
return client
}
// getReleaseJSONResourcesPF retrieves live Kubernetes resources from a Helm release and returns them as JSON.
func getReleaseJSONResourcesPF(t *testing.T, namespace, name string) map[string]string {
kc := getTestKubeClientPF(t, namespace)
cmd := exec.Command("helm", "--kubeconfig", os.Getenv("KUBE_CONFIG_PATH"), "get", "manifest", "--namespace", namespace, name)
manifest, err := cmd.Output()
if err != nil {
t.Fatalf("failed to get manifest for release %s/%s: %v", namespace, name, err)
}
resources, err := kc.Build(bytes.NewBuffer(manifest), false)
if err != nil {
t.Fatalf("failed to build resources: %v", err)
}
var objects []runtime.Object
err = resources.Visit(func(i *runtimeresource.Info, err error) error {
if err != nil {
return err
}
gvk := i.Object.GetObjectKind().GroupVersionKind()
obj, err := kc.Factory.NewBuilder().
Unstructured().
NamespaceParam(i.Namespace).DefaultNamespace().
ResourceNames(gvk.GroupKind().String(), i.Name).
Flatten().
Do().
Object()
if apierrors.IsNotFound(err) {
return nil
}
if err != nil {
return err
}
objects = append(objects, obj)
return nil
})
if err != nil {
t.Fatalf("error visiting resources: %v", err)
}
ctx := context.Background()
result, diags := mapRuntimeObjects(ctx, kc, objects)
if diags.HasError() {
t.Fatalf("failed to map runtime objects: %v", diags)
}
cleaned := make(map[string]string, len(result))
for k, v := range result {
var obj map[string]any
if err := json.Unmarshal([]byte(v), &obj); err != nil {
cleaned[k] = v
continue
}
normalizeK8sObject(obj)
b, err := json.Marshal(obj)
if err != nil {
cleaned[k] = v
continue
}
cleaned[k] = string(b)
}
return cleaned
}
// patchDeploymentPF patches a Deployment resource and waits until it stabilizes.
func patchDeploymentPF(t *testing.T, namespace, name string, patchBytes []byte) func() {
return func() {
kc := getTestKubeClientPF(t, namespace)
client, err := kc.Factory.KubernetesClientSet()
if err != nil {
t.Fatalf("failed to create kubernetes clientset: %v", err)
}
_, err = client.AppsV1().Deployments(namespace).Patch(
context.Background(), name, types.StrategicMergePatchType,
patchBytes, v1.PatchOptions{},
)
if err != nil {
t.Fatalf("failed to patch deployment: %v", err)
}
// Waiting for rollout
for {
dep, err := client.AppsV1().Deployments(namespace).Get(context.Background(), name, v1.GetOptions{})
if err != nil {
t.Fatalf("failed to get deployment: %v", err)
}
if dep.Status.UpdatedReplicas == *dep.Spec.Replicas &&
dep.Status.AvailableReplicas == *dep.Spec.Replicas &&
dep.Status.Replicas == *dep.Spec.Replicas {
break
}
time.Sleep(5 * time.Second)
}
}
}
func TestAccResourceRelease_manifestServerDiff(t *testing.T) {
name := randName("serverdiff")
namespace := createRandomNamespace(t)
defer deleteNamespace(t, namespace)
provider := `
provider "helm" {
experiments = { manifest = true }
}
`
config := provider + testAccHelmReleaseConfigBasic(testResourceName, namespace, name, "1.2.3")
fullName := fmt.Sprintf("%s-test-chart", name)
resource.Test(t, resource.TestCase{
ProtoV6ProviderFactories: protoV6ProviderFactories(),
Steps: []resource.TestStep{
{
Config: config,
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttr("helm_release.test", "metadata.name", name),
resource.TestCheckResourceAttr("helm_release.test", "metadata.namespace", namespace),
func(state *terraform.State) error {
t.Logf("fetching live JSON resources for %s/%s", namespace, name)
r := getReleaseJSONResourcesPF(t, namespace, name)
return checkResourceAttrMap("helm_release.test", "resources", r)(state)
},
),
},
{
PreConfig: patchDeploymentPF(t, namespace, fullName, []byte(`{"spec":{"replicas":2}}`)),
Config: config,
Check: checkDeploymentReplicasAndGeneration("helm_release.test", namespace, fullName, 1, 3),
},
},
})
}
func checkResourceAttrMap(resourceName, key string, expected map[string]string) resource.TestCheckFunc {
return func(s *terraform.State) error {
rs, ok := s.RootModule().Resources[resourceName]
if !ok {
return fmt.Errorf("resource %s not found in state", resourceName)
}
attrs := rs.Primary.Attributes
// Check count
countKey := fmt.Sprintf("%s.%%", key)
if got := attrs[countKey]; got != strconv.Itoa(len(expected)) {
return fmt.Errorf("expected %d entries in %q but got %s", len(expected), key, got)
}
// Check each resource attr
for k, v := range expected {
attrKey := fmt.Sprintf("%s.%s", key, k)
if got, ok := attrs[attrKey]; !ok || got != v {
return fmt.Errorf("expected %s=%q but got %q", attrKey, v, got)
}
}
return nil
}
}
func checkDeploymentReplicasAndGeneration(resourceName, namespace, deploymentName string, replicas int32, generation int64) resource.TestCheckFunc {
deploymentKey := fmt.Sprintf("resources.deployment.apps/v1/%s/%s", namespace, deploymentName)
return func(s *terraform.State) error {
rs, ok := s.RootModule().Resources[resourceName]
if !ok {
return fmt.Errorf("resource %s not found in state", resourceName)
}
val, ok := rs.Primary.Attributes[deploymentKey]
if !ok {
return fmt.Errorf("attribute %s not found in state", deploymentKey)
}
var deployment appsv1.Deployment
if err := json.Unmarshal([]byte(val), &deployment); err != nil {
return fmt.Errorf("failed to unmarshal deployment JSON: %w", err)
}
if deployment.Spec.Replicas == nil || *deployment.Spec.Replicas != replicas {
return fmt.Errorf("expected replicas=%d but got %v", replicas, deployment.Spec.Replicas)
}
if deployment.Generation != generation {
return fmt.Errorf("expected generation=%d but got %d", generation, deployment.Generation)
}
return nil
}
}