mirror of
https://github.com/hashicorp/terraform-provider-helm.git
synced 2025-12-18 23:26:08 -05:00
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:
parent
b278d36af3
commit
c8c5571ee1
9 changed files with 755 additions and 4 deletions
3
.changelog/1693.txt
Normal file
3
.changelog/1693.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
```release-note:feature
|
||||
Add `resources` attribute to manifest experimental feature
|
||||
```
|
||||
|
|
@ -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
3
go.mod
|
|
@ -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
6
go.sum
|
|
@ -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
312
helm/kube_resources.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue