// Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: MPL-2.0 package helm import ( "context" "encoding/json" "fmt" "net/url" "os" pathpkg "path" "strings" "time" "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/identityschema" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" "github.com/hashicorp/terraform-plugin-framework/resource/schema/defaults" "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/tfsdk" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/pkg/errors" "helm.sh/helm/v3/pkg/action" "helm.sh/helm/v3/pkg/chart" "helm.sh/helm/v3/pkg/chart/loader" "helm.sh/helm/v3/pkg/downloader" "helm.sh/helm/v3/pkg/getter" "helm.sh/helm/v3/pkg/postrender" "helm.sh/helm/v3/pkg/registry" "helm.sh/helm/v3/pkg/release" "k8s.io/helm/pkg/strvals" "sigs.k8s.io/yaml" ) var ( _ resource.Resource = &HelmRelease{} _ resource.ResourceWithModifyPlan = &HelmRelease{} _ resource.ResourceWithImportState = &HelmRelease{} _ resource.ResourceWithIdentity = &HelmRelease{} _ resource.ResourceWithUpgradeState = &HelmRelease{} ) type HelmRelease struct { meta *Meta } func NewHelmRelease() resource.Resource { return &HelmRelease{} } type HelmReleaseIdentityModel struct { Namespace types.String `tfsdk:"namespace"` ReleaseName types.String `tfsdk:"release_name"` } type HelmReleaseModel struct { Atomic types.Bool `tfsdk:"atomic"` Chart types.String `tfsdk:"chart"` CleanupOnFail types.Bool `tfsdk:"cleanup_on_fail"` CreateNamespace types.Bool `tfsdk:"create_namespace"` DependencyUpdate types.Bool `tfsdk:"dependency_update"` Description types.String `tfsdk:"description"` Devel types.Bool `tfsdk:"devel"` DisableCrdHooks types.Bool `tfsdk:"disable_crd_hooks"` DisableOpenapiValidation types.Bool `tfsdk:"disable_openapi_validation"` DisableWebhooks types.Bool `tfsdk:"disable_webhooks"` ForceUpdate types.Bool `tfsdk:"force_update"` ID types.String `tfsdk:"id"` Keyring types.String `tfsdk:"keyring"` Lint types.Bool `tfsdk:"lint"` Manifest types.String `tfsdk:"manifest"` MaxHistory types.Int64 `tfsdk:"max_history"` Metadata types.Object `tfsdk:"metadata"` Name types.String `tfsdk:"name"` Namespace types.String `tfsdk:"namespace"` PassCredentials types.Bool `tfsdk:"pass_credentials"` PostRender *PostRenderModel `tfsdk:"postrender"` RecreatePods types.Bool `tfsdk:"recreate_pods"` Replace types.Bool `tfsdk:"replace"` RenderSubchartNotes types.Bool `tfsdk:"render_subchart_notes"` Repository types.String `tfsdk:"repository"` RepositoryCaFile types.String `tfsdk:"repository_ca_file"` RepositoryCertFile types.String `tfsdk:"repository_cert_file"` RepositoryKeyFile types.String `tfsdk:"repository_key_file"` RepositoryPassword types.String `tfsdk:"repository_password"` RepositoryUsername types.String `tfsdk:"repository_username"` ResetValues types.Bool `tfsdk:"reset_values"` ReuseValues types.Bool `tfsdk:"reuse_values"` SetWO types.List `tfsdk:"set_wo"` SetWORevision types.Int64 `tfsdk:"set_wo_revision"` Set types.List `tfsdk:"set"` SetList types.List `tfsdk:"set_list"` SetSensitive types.List `tfsdk:"set_sensitive"` SkipCrds types.Bool `tfsdk:"skip_crds"` Status types.String `tfsdk:"status"` Timeout types.Int64 `tfsdk:"timeout"` Values types.List `tfsdk:"values"` Verify types.Bool `tfsdk:"verify"` Version types.String `tfsdk:"version"` Wait types.Bool `tfsdk:"wait"` WaitForJobs types.Bool `tfsdk:"wait_for_jobs"` } var defaultAttributes = map[string]interface{}{ "atomic": false, "cleanup_on_fail": false, "create_namespace": false, "dependency_update": false, "disable_crd_hooks": false, "disable_openapi_validation": false, "disable_webhooks": false, "force_update": false, "lint": false, "max_history": int64(0), "pass_credentials": false, "recreate_pods": false, "render_subchart_notes": true, "replace": false, "reset_values": false, "reuse_values": false, "skip_crds": false, "timeout": int64(300), "verify": false, "wait": true, "wait_for_jobs": false, } type releaseMetaData struct { AppVersion types.String `tfsdk:"app_version"` Chart types.String `tfsdk:"chart"` Name types.String `tfsdk:"name"` Namespace types.String `tfsdk:"namespace"` Revision types.Int64 `tfsdk:"revision"` Version types.String `tfsdk:"version"` Values types.String `tfsdk:"values"` FirstDeployed types.Int64 `tfsdk:"first_deployed"` LastDeployed types.Int64 `tfsdk:"last_deployed"` Notes types.String `tfsdk:"notes"` } type setResourceModel struct { Name types.String `tfsdk:"name"` Type types.String `tfsdk:"type"` Value types.String `tfsdk:"value"` } type set_listResourceModel struct { Name types.String `tfsdk:"name"` Value types.List `tfsdk:"value"` } type PostRenderModel struct { Args types.List `tfsdk:"args"` BinaryPath types.String `tfsdk:"binary_path"` } type suppressDescriptionPlanModifier struct{} func (m suppressDescriptionPlanModifier) Description(ctx context.Context) string { return "Suppress changes if the new description is an empty string" } func (m suppressDescriptionPlanModifier) MarkdownDescription(ctx context.Context) string { return m.Description(ctx) } func (m suppressDescriptionPlanModifier) PlanModifyString(ctx context.Context, req planmodifier.StringRequest, resp *planmodifier.StringResponse) { if req.PlanValue.IsNull() || req.PlanValue.ValueString() == "" { resp.PlanValue = req.StateValue } } func suppressDescription() planmodifier.String { return suppressDescriptionPlanModifier{} } type suppressDevelPlanModifier struct{} func (m suppressDevelPlanModifier) Description(ctx context.Context) string { return "Suppress changes if the version is set" } func (m suppressDevelPlanModifier) MarkdownDescription(ctx context.Context) string { return m.Description(ctx) } func (m suppressDevelPlanModifier) PlanModifyBool(ctx context.Context, req planmodifier.BoolRequest, resp *planmodifier.BoolResponse) { var version types.String req.Plan.GetAttribute(ctx, path.Root("version"), &version) if !version.IsNull() && version.ValueString() != "" { resp.PlanValue = req.StateValue } } func suppressDevel() planmodifier.Bool { return suppressDevelPlanModifier{} } // Supress Keyring type suppressKeyringPlanModifier struct{} func (m suppressKeyringPlanModifier) Description(ctx context.Context) string { return "Suppress changes if verify is false" } func (m suppressKeyringPlanModifier) MarkdownDescription(ctx context.Context) string { return m.Description(ctx) } func (m suppressKeyringPlanModifier) PlanModifyString(ctx context.Context, req planmodifier.StringRequest, resp *planmodifier.StringResponse) { var verify types.Bool req.Plan.GetAttribute(ctx, path.Root("verify"), &verify) if !verify.IsNull() && !verify.ValueBool() { resp.PlanValue = req.StateValue } } func suppressKeyring() planmodifier.String { return suppressKeyringPlanModifier{} } func namespaceDefault() defaults.String { return namespaceDefaultValue{} } type namespaceDefaultValue struct{} func (d namespaceDefaultValue) Description(ctx context.Context) string { return "If namespace is not provided, defaults to HELM_NAMESPACE environment variable or 'default'." } func (d namespaceDefaultValue) MarkdownDescription(ctx context.Context) string { return d.Description(ctx) } func (d namespaceDefaultValue) DefaultString(ctx context.Context, req defaults.StringRequest, resp *defaults.StringResponse) { envNamespace := os.Getenv("HELM_NAMESPACE") if envNamespace == "" { envNamespace = "default" } resp.PlanValue = types.StringValue(envNamespace) } func (r *HelmRelease) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { resp.TypeName = req.ProviderTypeName + "_release" } func (r *HelmRelease) UpgradeState(ctx context.Context) map[int64]resource.StateUpgrader { return r.buildUpgradeStateMap(ctx) } func (r *HelmRelease) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { resp.Schema = schema.Schema{ Description: "Schema to define attributes that are available in the resource", Attributes: map[string]schema.Attribute{ "atomic": schema.BoolAttribute{ Optional: true, Computed: true, Default: booldefault.StaticBool(defaultAttributes["atomic"].(bool)), Description: "If set, installation process purges chart on fail. The wait flag will be set automatically if atomic is used", }, "chart": schema.StringAttribute{ Required: true, Description: "Chart name to be installed. A path may be used", }, "cleanup_on_fail": schema.BoolAttribute{ Optional: true, Computed: true, Default: booldefault.StaticBool(defaultAttributes["cleanup_on_fail"].(bool)), Description: "Allow deletion of new resources created in this upgrade when upgrade fails", }, "create_namespace": schema.BoolAttribute{ Optional: true, Computed: true, Default: booldefault.StaticBool(defaultAttributes["create_namespace"].(bool)), Description: "Create the namespace if it does not exist", }, "dependency_update": schema.BoolAttribute{ Optional: true, Computed: true, Default: booldefault.StaticBool(defaultAttributes["dependency_update"].(bool)), Description: "Run helm dependency update before installing the chart", }, "description": schema.StringAttribute{ Optional: true, Description: "Add a custom description", PlanModifiers: []planmodifier.String{ suppressDescription(), }, }, "devel": schema.BoolAttribute{ Optional: true, Description: "Use chart development versions, too. Equivalent to version '>0.0.0-0'. If 'version' is set, this is ignored", PlanModifiers: []planmodifier.Bool{ suppressDevel(), }, }, "disable_crd_hooks": schema.BoolAttribute{ Optional: true, Computed: true, Default: booldefault.StaticBool(defaultAttributes["disable_crd_hooks"].(bool)), Description: "Prevent CRD hooks from running, but run other hooks. See helm install --no-crd-hook", }, "disable_openapi_validation": schema.BoolAttribute{ Optional: true, Computed: true, Default: booldefault.StaticBool(defaultAttributes["disable_openapi_validation"].(bool)), Description: "If set, the installation process will not validate rendered templates against the Kubernetes OpenAPI Schema", }, "disable_webhooks": schema.BoolAttribute{ Optional: true, Computed: true, Default: booldefault.StaticBool(defaultAttributes["disable_webhooks"].(bool)), Description: "Prevent hooks from running", }, "force_update": schema.BoolAttribute{ Optional: true, Computed: true, Default: booldefault.StaticBool(defaultAttributes["force_update"].(bool)), Description: "Force resource update through delete/recreate if needed.", }, "id": schema.StringAttribute{ Computed: true, }, "keyring": schema.StringAttribute{ Optional: true, Description: "Location of public keys used for verification, Used only if 'verify is true'", PlanModifiers: []planmodifier.String{ suppressKeyring(), }, }, "lint": schema.BoolAttribute{ Optional: true, Computed: true, Default: booldefault.StaticBool(defaultAttributes["lint"].(bool)), Description: "Run helm lint when planning", }, "manifest": schema.StringAttribute{ Description: "The rendered manifest as JSON.", Computed: true, }, "max_history": schema.Int64Attribute{ Optional: true, Computed: true, Default: int64default.StaticInt64(defaultAttributes["max_history"].(int64)), Description: "Limit the maximum number of revisions saved per release. Use 0 for no limit", }, "metadata": schema.SingleNestedAttribute{ Description: "Status of the deployed release.", Computed: true, Attributes: map[string]schema.Attribute{ "app_version": schema.StringAttribute{ Computed: true, Description: "The version number of the application being deployed", }, "chart": schema.StringAttribute{ Computed: true, Description: "The name of the chart", }, "first_deployed": schema.Int64Attribute{ Computed: true, Description: "FirstDeployed is an int64 which represents timestamp when the release was first deployed.", }, "last_deployed": schema.Int64Attribute{ Computed: true, Description: "LastDeployed is an int64 which represents timestamp when the release was last deployed.", }, "name": schema.StringAttribute{ Computed: true, Description: "Name is the name of the release", }, "namespace": schema.StringAttribute{ Computed: true, Description: "Namespace is the kubernetes namespace of the release", }, "notes": schema.StringAttribute{ Computed: true, Description: "Notes is the description of the deployed release, rendered from templates.", }, "revision": schema.Int64Attribute{ Computed: true, Description: "Version is an int32 which represents the version of the release", }, "values": schema.StringAttribute{ Computed: true, Description: "Set of extra values. added to the chart. The sensitive data is cloaked. JSON encoded.", }, "version": schema.StringAttribute{ Computed: true, Description: "A SemVer 2 conformant version string of the chart", }, }, }, "name": schema.StringAttribute{ Required: true, PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, Validators: []validator.String{ stringvalidator.LengthBetween(1, 53), }, Description: "Release name. The length must not be longer than 53 characters", }, "namespace": schema.StringAttribute{ Optional: true, Computed: true, Default: namespaceDefault(), PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, Description: "Namespace to install the release into", }, "pass_credentials": schema.BoolAttribute{ Optional: true, Description: "Pass credentials to all domains", Computed: true, Default: booldefault.StaticBool(defaultAttributes["pass_credentials"].(bool)), }, "recreate_pods": schema.BoolAttribute{ Optional: true, Computed: true, Default: booldefault.StaticBool(defaultAttributes["recreate_pods"].(bool)), Description: "Perform pods restart during upgrade/rollback", }, "render_subchart_notes": schema.BoolAttribute{ Optional: true, Computed: true, Default: booldefault.StaticBool(defaultAttributes["render_subchart_notes"].(bool)), Description: "If set, render subchart notes along with the parent", }, "replace": schema.BoolAttribute{ Optional: true, Computed: true, Default: booldefault.StaticBool(defaultAttributes["replace"].(bool)), Description: "Re-use the given name, even if that name is already used. This is unsafe in production", }, "repository": schema.StringAttribute{ Optional: true, Description: "Repository where to locate the requested chart. If it is a URL, the chart is installed without installing the repository", }, "repository_ca_file": schema.StringAttribute{ Optional: true, Description: "The Repositories CA file", }, "repository_cert_file": schema.StringAttribute{ Optional: true, Description: "The repositories cert file", }, "repository_key_file": schema.StringAttribute{ Optional: true, Description: "The repositories cert key file", }, "repository_password": schema.StringAttribute{ Optional: true, Sensitive: true, Description: "Password for HTTP basic authentication", }, "repository_username": schema.StringAttribute{ Optional: true, Description: "Username for HTTP basic authentication", }, "reset_values": schema.BoolAttribute{ Optional: true, Computed: true, Description: "When upgrading, reset the values to the ones built into the chart", Default: booldefault.StaticBool(defaultAttributes["reset_values"].(bool)), }, "reuse_values": schema.BoolAttribute{ Optional: true, Computed: true, 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)), }, "skip_crds": schema.BoolAttribute{ Optional: true, Computed: true, Default: booldefault.StaticBool(defaultAttributes["skip_crds"].(bool)), Description: "If set, no CRDs will be installed. By default, CRDs are installed if not already present", }, "status": schema.StringAttribute{ Computed: true, Description: "Status of the release", }, "timeout": schema.Int64Attribute{ Optional: true, Computed: true, Default: int64default.StaticInt64(defaultAttributes["timeout"].(int64)), Description: "Time in seconds to wait for any individual kubernetes operation", }, "values": schema.ListAttribute{ Optional: true, Description: "List of values in raw YAML format to pass to helm", ElementType: types.StringType, }, "verify": schema.BoolAttribute{ Optional: true, Computed: true, Default: booldefault.StaticBool(defaultAttributes["verify"].(bool)), Description: "Verify the package before installing it.", }, "version": schema.StringAttribute{ Optional: true, Computed: true, Description: "Specify the exact chart version to install. If this is not specified, the latest version is installed", }, "wait": schema.BoolAttribute{ Optional: true, Computed: true, Default: booldefault.StaticBool(defaultAttributes["wait"].(bool)), Description: "Will wait until all resources are in a ready state before marking the release as successful.", }, "wait_for_jobs": schema.BoolAttribute{ Optional: true, Computed: true, Default: booldefault.StaticBool(defaultAttributes["wait_for_jobs"].(bool)), Description: "If wait is enabled, will wait until all Jobs have been completed before marking the release as successful.", }, "set": schema.ListNestedAttribute{ Description: "Custom values to be merged with the values", Optional: true, NestedObject: schema.NestedAttributeObject{ Attributes: map[string]schema.Attribute{ "name": schema.StringAttribute{ Required: true, }, "value": schema.StringAttribute{ Optional: true, }, "type": schema.StringAttribute{ Optional: true, Computed: true, Default: stringdefault.StaticString(""), Validators: []validator.String{ stringvalidator.OneOf("auto", "string", "literal"), }, }, }, }, }, "set_wo": schema.ListNestedAttribute{ Description: "Custom values to be merged with the values", Optional: true, WriteOnly: true, NestedObject: schema.NestedAttributeObject{ Attributes: map[string]schema.Attribute{ "name": schema.StringAttribute{ Required: true, WriteOnly: true, }, "value": schema.StringAttribute{ Required: true, WriteOnly: true, }, "type": schema.StringAttribute{ Optional: true, WriteOnly: true, Validators: []validator.String{ stringvalidator.OneOf("auto", "string"), }, }, }, }, }, "set_wo_revision": schema.Int64Attribute{ Optional: true, Description: `The current revision of the write-only "set_wo" attribute. Incrementing this integer value will cause Terraform to update the write-only value.`, Validators: []validator.Int64{ int64validator.AtLeast(1), }, }, "set_list": schema.ListNestedAttribute{ Description: "Custom sensitive values to be merged with the values", Optional: true, NestedObject: schema.NestedAttributeObject{ Attributes: map[string]schema.Attribute{ "name": schema.StringAttribute{ Required: true, }, "value": schema.ListAttribute{ Required: true, ElementType: types.StringType, }, }, }, }, "set_sensitive": schema.ListNestedAttribute{ Description: "Custom sensitive values to be merged with the values", Optional: true, NestedObject: schema.NestedAttributeObject{ Attributes: map[string]schema.Attribute{ "name": schema.StringAttribute{ Required: true, }, "value": schema.StringAttribute{ Required: true, Sensitive: true, }, "type": schema.StringAttribute{ Optional: true, Validators: []validator.String{ stringvalidator.OneOf("auto", "string", "literal"), }, }, }, }, }, "postrender": schema.SingleNestedAttribute{ Description: "Postrender command config", Optional: true, Attributes: map[string]schema.Attribute{ "args": schema.ListAttribute{ Optional: true, Description: "An argument to the post-renderer (can specify multiple)", ElementType: types.StringType, }, "binary_path": schema.StringAttribute{ Required: true, Description: "The common binary path", }, }, }, }, Version: 2, } } func (r *HelmRelease) IdentitySchema(ctx context.Context, req resource.IdentitySchemaRequest, resp *resource.IdentitySchemaResponse) { resp.IdentitySchema = identityschema.Schema{ Version: 0, Attributes: map[string]identityschema.Attribute{ "namespace": identityschema.StringAttribute{ // use "default" if not specified OptionalForImport: true, }, "release_name": identityschema.StringAttribute{ RequiredForImport: true, }, }, } } func (r *HelmRelease) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { // Ensure that the ProviderData is not nil if req.ProviderData == nil { return } // Assert that the ProviderData is of type *Meta meta, ok := req.ProviderData.(*Meta) if !ok { resp.Diagnostics.AddError( "Provider Configuration Error", fmt.Sprintf("Unexpected ProviderData type: %T", req.ProviderData), ) return } tflog.Debug(ctx, fmt.Sprintf("Configured meta: %+v", meta)) r.meta = meta } const sensitiveContentValue = "(sensitive value)" func cloakSetValue(values map[string]interface{}, valuePath string) { pathKeys := strings.Split(valuePath, ".") sensitiveKey := pathKeys[len(pathKeys)-1] parentPathKeys := pathKeys[:len(pathKeys)-1] m := values for _, key := range parentPathKeys { v, ok := m[key].(map[string]interface{}) if !ok { return } m = v } m[sensitiveKey] = sensitiveContentValue } func mergeMaps(a, b map[string]interface{}) map[string]interface{} { out := make(map[string]interface{}, len(a)) for k, v := range a { out[k] = v } for k, v := range b { if vMap, ok := v.(map[string]interface{}); ok { if bv, ok := out[k]; ok { if bvMap, ok := bv.(map[string]interface{}); ok { out[k] = mergeMaps(bvMap, vMap) continue } } } out[k] = v } return out } func (r *HelmRelease) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { var state HelmReleaseModel diags := req.Plan.Get(ctx, &state) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } var config HelmReleaseModel diags = req.Config.Get(ctx, &config) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } tflog.Debug(ctx, fmt.Sprintf("Plan state on Create: %+v", state)) meta := r.meta if meta == nil { resp.Diagnostics.AddError("Initialization Error", "Meta instance is not initialized") return } namespace := state.Namespace.ValueString() actionConfig, err := meta.GetHelmConfiguration(ctx, namespace) if err != nil { resp.Diagnostics.AddError("Error getting helm configuration", fmt.Sprintf("Unable to get Helm configuration for namespace %s: %s", namespace, err)) return } ociDiags := OCIRegistryLogin(ctx, meta, actionConfig, meta.RegistryClient, state.Repository.ValueString(), state.Chart.ValueString(), state.RepositoryUsername.ValueString(), state.RepositoryPassword.ValueString()) resp.Diagnostics.Append(ociDiags...) if resp.Diagnostics.HasError() { return } client := action.NewInstall(actionConfig) cpo, chartName, cpoDiags := chartPathOptions(&state, meta, &client.ChartPathOptions) resp.Diagnostics.Append(cpoDiags...) if resp.Diagnostics.HasError() { return } c, cpath, chartDiags := getChart(ctx, &state, meta, chartName, cpo) resp.Diagnostics.Append(chartDiags...) if resp.Diagnostics.HasError() { return } updated, depDiags := checkChartDependencies(ctx, &state, c, cpath, meta) resp.Diagnostics.Append(depDiags...) if resp.Diagnostics.HasError() { return } else if updated { c, err = loader.Load(cpath) if err != nil { resp.Diagnostics.AddError("Error loading chart", fmt.Sprintf("Could not load chart: %s", err)) return } } values, valuesDiags := getValues(ctx, &state) resp.Diagnostics.Append(valuesDiags...) if resp.Diagnostics.HasError() { return } if config.SetWORevision.ValueInt64() > 0 { woValues, woDiags := getWriteOnlyValues(ctx, &config) resp.Diagnostics.Append(woDiags...) if resp.Diagnostics.HasError() { return } if len(woValues) > 0 { values = mergeMaps(values, woValues) } } err = isChartInstallable(c) if err != nil { resp.Diagnostics.AddError("Error checking if chart is installable", fmt.Sprintf("Chart is not installable: %s", err)) return } client.ClientOnly = false client.DryRun = false client.DisableHooks = state.DisableWebhooks.ValueBool() client.Wait = state.Wait.ValueBool() client.WaitForJobs = state.WaitForJobs.ValueBool() client.Devel = state.Devel.ValueBool() client.DependencyUpdate = state.DependencyUpdate.ValueBool() client.Timeout = time.Duration(state.Timeout.ValueInt64()) * time.Second client.Namespace = state.Namespace.ValueString() client.ReleaseName = state.Name.ValueString() client.Atomic = state.Atomic.ValueBool() client.SkipCRDs = state.SkipCrds.ValueBool() client.SubNotes = state.RenderSubchartNotes.ValueBool() client.DisableOpenAPIValidation = state.DisableOpenapiValidation.ValueBool() client.Replace = state.Replace.ValueBool() client.Description = state.Description.ValueString() client.CreateNamespace = state.CreateNamespace.ValueBool() if state.PostRender != nil { binaryPath := state.PostRender.BinaryPath.ValueString() argsList := state.PostRender.Args.Elements() var args []string for _, arg := range argsList { args = append(args, arg.(basetypes.StringValue).ValueString()) } tflog.Debug(ctx, fmt.Sprintf("Creating post-renderer with binary path: %s and args: %v", binaryPath, args)) pr, err := postrender.NewExec(binaryPath, args...) if err != nil { resp.Diagnostics.AddError("Error creating post-renderer", fmt.Sprintf("Could not create post-renderer: %s", err)) return } client.PostRenderer = pr } rel, err := client.Run(c, values) if err != nil && rel == nil { resp.Diagnostics.AddError("installation failed", err.Error()) return } if err != nil && rel != nil { exists, existsDiags := resourceReleaseExists(ctx, state.Name.ValueString(), state.Namespace.ValueString(), meta) resp.Diagnostics.Append(existsDiags...) if resp.Diagnostics.HasError() { return } if !exists { resp.Diagnostics.AddError("installation failed", err.Error()) return } diags := setReleaseAttributes(ctx, &state, resp.Identity, rel, meta) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } resp.Diagnostics.Append(diag.NewWarningDiagnostic("Helm release created with warnings", fmt.Sprintf("Helm release %q was created but has a failed status. Use the `helm` command to investigate the error, correct it, then run Terraform again.", client.ReleaseName))) resp.Diagnostics.Append(diag.NewErrorDiagnostic("Helm release error", err.Error())) return } diags = setReleaseAttributes(ctx, &state, resp.Identity, rel, meta) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } diags = resp.State.Set(ctx, &state) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } } func (r *HelmRelease) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { var state HelmReleaseModel diags := req.State.Get(ctx, &state) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } tflog.Debug(ctx, fmt.Sprintf("Current state before changes: %+v", state)) meta := r.meta if meta == nil { resp.Diagnostics.AddError( "Meta not set", "The meta information is not set for the resource", ) return } exists, diags := resourceReleaseExists(ctx, state.Name.ValueString(), state.Namespace.ValueString(), meta) if !exists { resp.State.RemoveResource(ctx) return } resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } logID := fmt.Sprintf("[resourceReleaseRead: %s]", state.Name.ValueString()) tflog.Debug(ctx, fmt.Sprintf("%s Started", logID)) c, err := meta.GetHelmConfiguration(ctx, state.Namespace.ValueString()) if err != nil { resp.Diagnostics.AddError( "Error getting helm configuration", fmt.Sprintf("Unable to get Helm configuration for namespace %s: %s", state.Namespace.ValueString(), err), ) return } release, err := getRelease(ctx, meta, c, state.Name.ValueString()) if err != nil { resp.Diagnostics.AddError( "Error getting release", fmt.Sprintf("Unable to get Helm release %s: %s", state.Name.ValueString(), err.Error()), ) return } diags = setReleaseAttributes(ctx, &state, resp.Identity, release, meta) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { resp.Diagnostics.AddError( "Error setting release attributes", fmt.Sprintf("Unable to set attributes for helm release %s", state.Name.ValueString()), ) return } resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) if resp.Diagnostics.HasError() { return } } func (r *HelmRelease) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { var plan HelmReleaseModel diags := req.Plan.Get(ctx, &plan) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } // Current state of the resource before update operation is applied var state HelmReleaseModel diags = req.State.Get(ctx, &state) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } var config HelmReleaseModel diags = req.Config.Get(ctx, &config) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } logID := fmt.Sprintf("[resourceReleaseUpdate: %s]", state.Name.ValueString()) tflog.Debug(ctx, fmt.Sprintf("%s Started", logID)) meta := r.meta namespace := state.Namespace.ValueString() tflog.Debug(ctx, fmt.Sprintf("%s Getting helm configuration for namespace: %s", logID, namespace)) actionConfig, err := meta.GetHelmConfiguration(ctx, namespace) if err != nil { tflog.Debug(ctx, fmt.Sprintf("%s Failed to get helm configuration: %v", logID, err)) resp.Diagnostics.AddError("Error getting helm configuration", fmt.Sprintf("Unable to get Helm configuration for namespace %s: %s", namespace, err)) return } ociDiags := OCIRegistryLogin(ctx, meta, actionConfig, meta.RegistryClient, state.Repository.ValueString(), state.Chart.ValueString(), state.RepositoryUsername.ValueString(), state.RepositoryPassword.ValueString()) resp.Diagnostics.Append(ociDiags...) if resp.Diagnostics.HasError() { return } client := action.NewUpgrade(actionConfig) cpo, chartName, cpoDiags := chartPathOptions(&plan, meta, &client.ChartPathOptions) resp.Diagnostics.Append(cpoDiags...) if resp.Diagnostics.HasError() { return } c, path, chartDiags := getChart(ctx, &plan, meta, chartName, cpo) resp.Diagnostics.Append(chartDiags...) if resp.Diagnostics.HasError() { return } // Check and update the chart's depenedcies if it's needed updated, depDiags := checkChartDependencies(ctx, &plan, c, path, meta) resp.Diagnostics.Append(depDiags...) if resp.Diagnostics.HasError() { return } else if updated { c, err = loader.Load(path) if err != nil { resp.Diagnostics.AddError("Error loading chart", fmt.Sprintf("Could not load chart: %s", err)) return } } client.Devel = plan.Devel.ValueBool() client.Namespace = plan.Namespace.ValueString() client.Timeout = time.Duration(plan.Timeout.ValueInt64()) * time.Second client.Wait = plan.Wait.ValueBool() client.WaitForJobs = plan.WaitForJobs.ValueBool() client.DryRun = false client.DisableHooks = plan.DisableWebhooks.ValueBool() client.Atomic = plan.Atomic.ValueBool() client.SkipCRDs = plan.SkipCrds.ValueBool() client.SubNotes = plan.RenderSubchartNotes.ValueBool() client.DisableOpenAPIValidation = plan.DisableOpenapiValidation.ValueBool() client.Force = plan.ForceUpdate.ValueBool() client.ResetValues = plan.ResetValues.ValueBool() client.ReuseValues = plan.ReuseValues.ValueBool() client.Recreate = plan.RecreatePods.ValueBool() client.MaxHistory = int(plan.MaxHistory.ValueInt64()) client.CleanupOnFail = plan.CleanupOnFail.ValueBool() client.Description = plan.Description.ValueString() if plan.PostRender != nil { binaryPath := plan.PostRender.BinaryPath.ValueString() argsList := plan.PostRender.Args.Elements() var args []string for _, arg := range argsList { args = append(args, arg.(basetypes.StringValue).ValueString()) } tflog.Debug(ctx, fmt.Sprintf("Binary path update method: %s, Args: %v", binaryPath, args)) pr, err := postrender.NewExec(binaryPath, args...) if err != nil { resp.Diagnostics.AddError("Error creating post-renderer", fmt.Sprintf("Could not create post-renderer: %s", err)) return } client.PostRenderer = pr } values, valuesDiags := getValues(ctx, &plan) resp.Diagnostics.Append(valuesDiags...) if resp.Diagnostics.HasError() { return } if plan.SetWORevision.ValueInt64() > state.SetWORevision.ValueInt64() { woValues, woDiags := getWriteOnlyValues(ctx, &config) resp.Diagnostics.Append(woDiags...) if resp.Diagnostics.HasError() { return } if len(woValues) > 0 { values = mergeMaps(values, woValues) } } name := plan.Name.ValueString() release, err := client.Run(name, c, values) if err != nil { resp.Diagnostics.AddError("Error upgrading chart", fmt.Sprintf("Upgrade failed: %s", err)) return } diags = setReleaseAttributes(ctx, &plan, resp.Identity, release, meta) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } diags = resp.State.Set(ctx, &plan) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } } func (r *HelmRelease) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { var state HelmReleaseModel diags := req.State.Get(ctx, &state) for _, diag := range diags { tflog.Debug(ctx, fmt.Sprintf("Diagnostics after state get: %s", diag.Detail())) } // Append diagnostics to response resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { tflog.Error(ctx, fmt.Sprintf("Error retrieving state: %v", resp.Diagnostics)) return } tflog.Debug(ctx, fmt.Sprintf("Retrieved state: %+v", state)) // Check if meta is set meta := r.meta if meta == nil { resp.Diagnostics.AddError( "Meta not set", "The meta information is not set for the resource", ) tflog.Error(ctx, "Meta information is not set for the resource") return } name := state.Name.ValueString() namespace := state.Namespace.ValueString() exists, diags := resourceReleaseExists(ctx, name, namespace, meta) if !exists { return } resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } // Get Helm configuration actionConfig, err := meta.GetHelmConfiguration(ctx, namespace) if err != nil { resp.Diagnostics.AddError( "Error getting helm configuration", fmt.Sprintf("Unable to get Helm configuration for namespace %s: %s", namespace, err), ) tflog.Error(ctx, fmt.Sprintf("Unable to get Helm configuration for namespace %s: %s", namespace, err)) return } tflog.Debug(ctx, fmt.Sprintf("Retrieved Helm configuration for namespace: %s", namespace)) // Initialize uninstall action uninstall := action.NewUninstall(actionConfig) uninstall.Wait = state.Wait.ValueBool() uninstall.DisableHooks = state.DisableWebhooks.ValueBool() uninstall.Timeout = time.Duration(state.Timeout.ValueInt64()) * time.Second // Uninstall the release tflog.Info(ctx, fmt.Sprintf("Uninstalling Helm release: %s", name)) res, err := uninstall.Run(name) if err != nil { resp.Diagnostics.AddError( "Error uninstalling release", fmt.Sprintf("Unable to uninstall Helm release %s: %s", name, err), ) tflog.Error(ctx, fmt.Sprintf("Unable to uninstall Helm release %s: %s", name, err)) return } if res.Info != "" { resp.Diagnostics.Append(diag.NewWarningDiagnostic( "Helm uninstall returned an information message", res.Info, )) } } func chartPathOptions(model *HelmReleaseModel, meta *Meta, cpo *action.ChartPathOptions) (*action.ChartPathOptions, string, diag.Diagnostics) { var diags diag.Diagnostics chartName := model.Chart.ValueString() repository := model.Repository.ValueString() var repositoryURL string if registry.IsOCI(repository) { // LocateChart expects the chart name to contain the full OCI path u, err := url.Parse(repository) if err != nil { diags.AddError("Invalid Repository URL", fmt.Sprintf("Failed to parse repository URL %s: %s", repository, err)) return nil, "", diags } u.Path = pathpkg.Join(u.Path, chartName) chartName = u.String() } else { var err error repositoryURL, chartName, err = buildChartNameWithRepository(repository, strings.TrimSpace(chartName)) if err != nil { diags.AddError("Error building Chart Name With Repository", fmt.Sprintf("Could not build Chart Name With Repository %s and chart %s: %s", repository, chartName, err)) return nil, "", diags } } version := getVersion(model) cpo.CaFile = model.RepositoryCaFile.ValueString() cpo.CertFile = model.RepositoryCertFile.ValueString() cpo.KeyFile = model.RepositoryKeyFile.ValueString() cpo.Keyring = model.Keyring.ValueString() cpo.RepoURL = repositoryURL cpo.Verify = model.Verify.ValueBool() if !useChartVersion(chartName, cpo.RepoURL) { cpo.Version = version } cpo.Username = model.RepositoryUsername.ValueString() cpo.Password = model.RepositoryPassword.ValueString() cpo.PassCredentialsAll = model.PassCredentials.ValueBool() return cpo, chartName, diags } func useChartVersion(chart string, repo string) bool { // checks if chart is a URL or OCI registry if _, err := url.ParseRequestURI(chart); err == nil && !registry.IsOCI(chart) { return true } // checks if chart is a local chart if _, err := os.Stat(chart); err == nil { return true } // checks if repo is a local chart if _, err := os.Stat(repo); err == nil { return true } return false } func buildChartNameWithRepository(repository, name string) (string, string, error) { _, err := url.ParseRequestURI(repository) if err == nil { return repository, name, nil } if strings.Index(name, "/") == -1 && repository != "" { name = fmt.Sprintf("%s/%s", repository, name) } return "", name, nil } func getVersion(model *HelmReleaseModel) string { version := model.Version.ValueString() if version == "" && model.Devel.ValueBool() { return ">0.0.0-0" } return strings.TrimSpace(version) } func isChartInstallable(ch *chart.Chart) error { switch ch.Metadata.Type { case "", "application": return nil } return errors.Errorf("%s charts are not installable", ch.Metadata.Type) } func getChart(ctx context.Context, model *HelmReleaseModel, m *Meta, name string, cpo *action.ChartPathOptions) (*chart.Chart, string, diag.Diagnostics) { var diags diag.Diagnostics tflog.Debug(ctx, fmt.Sprintf("Helm settings: %+v", m.Settings)) path, err := cpo.LocateChart(name, m.Settings) if err != nil { diags.AddError("Error locating chart", fmt.Sprintf("Unable to locate chart %s: %s", name, err)) return nil, "", diags } c, err := loader.Load(path) if err != nil { diags.AddError("Error loading chart", fmt.Sprintf("Unable to load chart %s: %s", path, err)) return nil, "", diags } return c, path, diags } func getWriteOnlyValues(ctx context.Context, model *HelmReleaseModel) (map[string]interface{}, diag.Diagnostics) { base := map[string]interface{}{} diags := diag.Diagnostics{} if !model.SetWO.IsUnknown() && !model.SetWO.IsNull() { tflog.Debug(ctx, "Processing SetWO attribute") var setvals []setResourceModel setDiags := model.SetWO.ElementsAs(ctx, &setvals, false) diags.Append(setDiags...) if diags.HasError() { return nil, diags } for _, set := range setvals { setDiags := getValue(base, set) diags.Append(setDiags...) if diags.HasError() { return nil, diags } } } return base, diags } func getValues(ctx context.Context, model *HelmReleaseModel) (map[string]interface{}, diag.Diagnostics) { base := map[string]interface{}{} var diags diag.Diagnostics // Processing "values" attribute for _, raw := range model.Values.Elements() { if raw.IsNull() { continue } value, ok := raw.(types.String) if !ok { diags.AddError("Type Error", fmt.Sprintf("Expected types.String, got %T", raw)) return nil, diags } values := value.ValueString() if values == "" { continue } currentMap := map[string]interface{}{} if err := yaml.Unmarshal([]byte(values), ¤tMap); err != nil { diags.AddError("Error unmarshaling values", fmt.Sprintf("---> %v %s", err, values)) return nil, diags } base = mergeMaps(base, currentMap) } // Processing "set" attribute if !model.Set.IsNull() { tflog.Debug(ctx, "Processing Set attribute") var setList []setResourceModel setDiags := model.Set.ElementsAs(ctx, &setList, false) diags.Append(setDiags...) if diags.HasError() { return nil, diags } for i, set := range setList { tflog.Debug(ctx, fmt.Sprintf("Processing Set element at index %d: %v", i, set)) setDiags := getValue(base, set) diags.Append(setDiags...) if diags.HasError() { tflog.Debug(ctx, fmt.Sprintf("Error occurred while processing Set element at index %d", i)) return nil, diags } } } // Processing "set_list" attribute if !model.SetList.IsUnknown() { tflog.Debug(ctx, "Processing Set_list attribute") var setListSlice []set_listResourceModel setListDiags := model.SetList.ElementsAs(ctx, &setListSlice, false) diags.Append(setListDiags...) if diags.HasError() { tflog.Debug(ctx, "Error occurred while processing Set_list attribute") return nil, diags } for i, setList := range setListSlice { tflog.Debug(ctx, fmt.Sprintf("Processing Set_list element at index %d: %v", i, setList)) setListDiags := getListValue(ctx, base, setList) diags.Append(setListDiags...) if diags.HasError() { tflog.Debug(ctx, fmt.Sprintf("Error occurred while processing Set_list element at index %d", i)) return nil, diags } } } // Processing "set_sensitive" attribute if !model.SetSensitive.IsNull() { tflog.Debug(ctx, "Processing Set_Sensitive attribute") var setSensitiveList []setResourceModel setSensitiveDiags := model.SetSensitive.ElementsAs(ctx, &setSensitiveList, false) diags.Append(setSensitiveDiags...) if diags.HasError() { tflog.Debug(ctx, "Error occurred while processing Set_Sensitive attribute") return nil, diags } for i, setSensitive := range setSensitiveList { tflog.Debug(ctx, fmt.Sprintf("Processing Set_Sensitive element at index %d: %v", i, setSensitive)) setSensitiveDiags := getValue(base, setSensitive) diags.Append(setSensitiveDiags...) if diags.HasError() { tflog.Debug(ctx, fmt.Sprintf("Error occurred while processing Set_Sensitive element at index %d", i)) return nil, diags } } } tflog.Debug(ctx, fmt.Sprintf("Final merged values: %v", base)) logDiags := logValues(ctx, base, model) diags.Append(logDiags...) if diags.HasError() { tflog.Debug(ctx, "Error occurred while logging values") return nil, diags } return base, diags } func getValue(base map[string]interface{}, set setResourceModel) diag.Diagnostics { var diags diag.Diagnostics name := set.Name.ValueString() value := set.Value.ValueString() valueType := set.Type.ValueString() switch valueType { case "auto", "": if err := strvals.ParseInto(fmt.Sprintf("%s=%s", name, value), base); err != nil { diags.AddError("Failed parsing value", fmt.Sprintf("Failed parsing key %q with value %s: %s", name, value, err)) return diags } case "string": if err := strvals.ParseIntoString(fmt.Sprintf("%s=%s", name, value), base); err != nil { diags.AddError("Failed parsing string value", fmt.Sprintf("Failed parsing key %q with value %s: %s", name, value, err)) return diags } case "literal": var literal interface{} if err := yaml.Unmarshal([]byte(fmt.Sprintf("%s: %s", name, value)), &literal); err != nil { diags.AddError("Failed parsing literal value", fmt.Sprintf("Key %q with literal value %s: %s", name, value, err)) return diags } if m, ok := literal.(map[string]interface{}); ok { base[name] = m[name] } else { base[name] = literal } default: diags.AddError("Unexpected type", fmt.Sprintf("Unexpected type: %s", valueType)) return diags } return diags } // deepCloneMap creates a deep copy of a map[string]interface{} func deepCloneMap(m map[string]interface{}) map[string]interface{} { if m == nil { return nil } clone := make(map[string]interface{}, len(m)) for k, v := range m { switch val := v.(type) { case map[string]interface{}: clone[k] = deepCloneMap(val) default: clone[k] = v } } return clone } func logValues(ctx context.Context, values map[string]interface{}, state *HelmReleaseModel) diag.Diagnostics { var diags diag.Diagnostics // Deep cloning values map to avoid modifying the original c := deepCloneMap(values) cloakSetValues(c, state) y, err := yaml.Marshal(c) if err != nil { diags.AddError("Error marshaling map to YAML", fmt.Sprintf("Failed to marshal map to YAML: %s", err)) return diags } tflog.Debug(ctx, fmt.Sprintf("---[ values.yaml ]-----------------------------------\n%s\n", string(y))) return diags } func cloakSetValues(config map[string]interface{}, state *HelmReleaseModel) { if !state.SetSensitive.IsNull() { var setSensitiveList []setResourceModel diags := state.SetSensitive.ElementsAs(context.Background(), &setSensitiveList, false) if diags.HasError() { // Handle diagnostics error return } for _, set := range setSensitiveList { cloakSetValue(config, set.Name.ValueString()) } } } func getListValue(ctx context.Context, base map[string]interface{}, set set_listResourceModel) diag.Diagnostics { var diags diag.Diagnostics name := set.Name.ValueString() if set.Value.IsNull() { diags.AddError("Null List Value", "The list value is null.") return diags } // Get the elements of the ListValue elements := set.Value.Elements() // Convert elements to a list of strings listStringArray := make([]string, 0, len(elements)) for _, element := range elements { if !element.IsNull() { strValue := element.(types.String).ValueString() listStringArray = append(listStringArray, strValue) } } // Join the list into a single string listString := strings.Join(listStringArray, ",") if err := strvals.ParseInto(fmt.Sprintf("%s={%s}", name, listString), base); err != nil { diags.AddError("Error parsing list value", fmt.Sprintf("Failed parsing key %q with value %s: %s", name, listString, err)) return diags } return diags } func versionsEqual(a, b string) bool { return strings.TrimPrefix(a, "v") == strings.TrimPrefix(b, "v") } 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.Name = types.StringValue(r.Name) version := r.Chart.Metadata.Version if !versionsEqual(version, state.Version.ValueString()) { state.Version = types.StringValue(version) } state.Namespace = types.StringValue(r.Namespace) state.Status = types.StringValue(r.Info.Status.String()) state.ID = types.StringValue(r.Name) rid := HelmReleaseIdentityModel{ Namespace: types.StringValue(r.Namespace), ReleaseName: types.StringValue(r.Name), } diags = identity.Set(ctx, rid) if diags.HasError() { return diags } // Cloak sensitive values in the release config values := "{}" if r.Config != nil { // Deep clone the config to avoid modifying the original configClone := deepCloneMap(r.Config) cloakSetValues(configClone, state) v, err := json.Marshal(configClone) if err != nil { diags.AddError( "Error marshaling values", fmt.Sprintf("unable to marshal values: %s", err), ) return diags } values = string(v) } // Handling the helm release if manifest experiment is enabled if meta.ExperimentEnabled("manifest") { jsonManifest, err := convertYAMLManifestToJSON(r.Manifest) if err != nil { diags.AddError( "Error converting manifest to JSON", fmt.Sprintf("Unable to convert manifest to JSON: %s", err), ) return diags } sensitiveValues := extractSensitiveValues(state) manifest := redactSensitiveValues(string(jsonManifest), sensitiveValues) state.Manifest = types.StringValue(manifest) } // NOTE Don't retrieve values if write-only is being used. // It is not possible to pick out which values are write-only // at read time because write-only values are ephemeral valuesstr := types.StringValue("{}") if state.SetWORevision.ValueInt64() <= 0 { valuesstr = types.StringValue(values) } // Create metadata as a slice of maps metadata := map[string]attr.Value{ "name": types.StringValue(r.Name), "revision": types.Int64Value(int64(r.Version)), "namespace": types.StringValue(r.Namespace), "chart": types.StringValue(r.Chart.Metadata.Name), "version": types.StringValue(r.Chart.Metadata.Version), "app_version": types.StringValue(r.Chart.Metadata.AppVersion), "values": valuesstr, "first_deployed": types.Int64Value(r.Info.FirstDeployed.Unix()), "last_deployed": types.Int64Value(r.Info.LastDeployed.Unix()), "notes": types.StringValue(r.Info.Notes), } // Convert the list of ObjectValues to a ListValue metadataObject, diag := types.ObjectValue(metadataAttrTypes(), metadata) diags.Append(diag...) if diags.HasError() { tflog.Error(ctx, "Error converting metadata to ListValue", map[string]interface{}{ "metadata": metadata, "error": diags, }) return diags } // Log metadata after conversion tflog.Debug(ctx, fmt.Sprintf("Metadata after conversion: %+v", metadataObject)) state.Metadata = metadataObject return diags } func metadataAttrTypes() map[string]attr.Type { return map[string]attr.Type{ "name": types.StringType, "revision": types.Int64Type, "namespace": types.StringType, "chart": types.StringType, "version": types.StringType, "app_version": types.StringType, "values": types.StringType, "first_deployed": types.Int64Type, "last_deployed": types.Int64Type, "notes": types.StringType, } } func extractSensitiveValues(state *HelmReleaseModel) map[string]string { sensitiveValues := make(map[string]string) if !state.SetSensitive.IsNull() { var setSensitiveList []setResourceModel diags := state.SetSensitive.ElementsAs(context.Background(), &setSensitiveList, false) if diags.HasError() { return sensitiveValues } for _, set := range setSensitiveList { sensitiveValues[set.Name.ValueString()] = "(sensitive value)" } } return sensitiveValues } func (m *Meta) ExperimentEnabled(name string) bool { if enabled, exists := m.Experiments[name]; exists { return enabled } return false } // c func resourceReleaseExists(ctx context.Context, name, namespace string, meta *Meta) (bool, diag.Diagnostics) { logID := fmt.Sprintf("[resourceReleaseExists: %s]", name) tflog.Debug(ctx, fmt.Sprintf("%s Start", logID)) var diags diag.Diagnostics c, err := meta.GetHelmConfiguration(ctx, namespace) if err != nil { diags.AddError( "Error getting helm configuration", fmt.Sprintf("Unable to get Helm configuration for namespace %s: %s", namespace, err), ) return false, diags } _, err = getRelease(ctx, meta, c, name) tflog.Debug(ctx, fmt.Sprintf("%s Done", logID)) if err == nil { return true, diags } if err == errReleaseNotFound { return false, diags } diags.AddError( "Error checking release existence", fmt.Sprintf("Error checking release %s in namespace %s: %s", name, namespace, err), ) return false, diags } var errReleaseNotFound = fmt.Errorf("release: not found") // c func getRelease(ctx context.Context, m *Meta, cfg *action.Configuration, name string) (*release.Release, error) { get := action.NewGet(cfg) tflog.Debug(ctx, fmt.Sprintf("%s getRelease post action created", name)) res, err := get.Run(name) tflog.Debug(ctx, fmt.Sprintf("%s getRelease post run", name)) if err != nil { tflog.Debug(ctx, fmt.Sprintf("getRelease for %s occurred", name)) tflog.Debug(ctx, fmt.Sprintf("%v", err)) if strings.Contains(err.Error(), "release: not found") { tflog.Error(ctx, errReleaseNotFound.Error()) return nil, errReleaseNotFound } tflog.Debug(ctx, fmt.Sprintf("Could not get release %s", err)) tflog.Error(ctx, err.Error()) return nil, err } tflog.Debug(ctx, fmt.Sprintf("%s getRelease completed", name)) return res, nil } // c func checkChartDependencies(ctx context.Context, model *HelmReleaseModel, c *chart.Chart, path string, m *Meta) (bool, diag.Diagnostics) { var diags diag.Diagnostics p := getter.All(m.Settings) if req := c.Metadata.Dependencies; req != nil { err := action.CheckDependencies(c, req) if err != nil { if model.DependencyUpdate.ValueBool() { man := &downloader.Manager{ Out: os.Stdout, ChartPath: path, Keyring: model.Keyring.ValueString(), SkipUpdate: false, Getters: p, RepositoryConfig: m.Settings.RepositoryConfig, RepositoryCache: m.Settings.RepositoryCache, Debug: m.Settings.Debug, } tflog.Debug(ctx, "Downloading chart dependencies...") if err := man.Update(); err != nil { diags.AddError("", fmt.Sprintf("Failed to update chart dependencies: %s", err)) return true, diags } return true, diags } diags.AddError("", "Found in Chart.yaml, but missing in charts/ directory") return false, diags } } tflog.Debug(ctx, "Chart dependencies are up to date.") return false, diags } func (r *HelmRelease) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { if req.Plan.Raw.IsNull() { // resource is being destroyed return } var plan HelmReleaseModel resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) if resp.Diagnostics.HasError() { return } var state *HelmReleaseModel resp.Diagnostics.Append(req.State.Get(ctx, &state)...) if resp.Diagnostics.HasError() { return } var config HelmReleaseModel resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) if resp.Diagnostics.HasError() { return } tflog.Debug(ctx, fmt.Sprintf("Plan state on ModifyPlan: %+v", plan)) tflog.Debug(ctx, fmt.Sprintf("Actual state on ModifyPlan: %+v", state)) logID := fmt.Sprintf("[resourceDiff: %s]", plan.Name.ValueString()) tflog.Debug(ctx, fmt.Sprintf("%s Start", logID)) meta := r.meta name := plan.Name.ValueString() namespace := plan.Namespace.ValueString() actionConfig, err := meta.GetHelmConfiguration(ctx, namespace) if err != nil { resp.Diagnostics.AddError("Error getting Helm configuration", err.Error()) return } tflog.Debug(ctx, fmt.Sprintf("%s Initial Values: Name=%s, Namespace=%s, Repository=%s, Repository_Username=%s, Repository_Password=%s, Chart=%s", logID, name, namespace, plan.Repository.ValueString(), plan.RepositoryUsername.ValueString(), plan.RepositoryPassword.ValueString(), plan.Chart.ValueString())) repositoryURL := plan.Repository.ValueString() repositoryUsername := plan.RepositoryUsername.ValueString() repositoryPassword := plan.RepositoryPassword.ValueString() chartName := plan.Chart.ValueString() ociDiags := OCIRegistryLogin(ctx, meta, actionConfig, meta.RegistryClient, repositoryURL, chartName, repositoryUsername, repositoryPassword) resp.Diagnostics.Append(ociDiags...) if resp.Diagnostics.HasError() { return } // Always set desired state to DEPLOYED plan.Status = types.StringValue(release.StatusDeployed.String()) if recomputeMetadata(plan, state) { tflog.Debug(ctx, fmt.Sprintf("%s Metadata has changes, setting to unknown", logID)) plan.Metadata = types.ObjectUnknown(metadataAttrTypes()) } if !useChartVersion(plan.Chart.ValueString(), plan.Repository.ValueString()) { // Check if version has changed if state != nil && !plan.Version.Equal(state.Version) { // Ensure trimming 'v' prefix correctly oldVersionStr := strings.TrimPrefix(state.Version.String(), "v") newVersionStr := strings.TrimPrefix(plan.Version.String(), "v") if oldVersionStr != newVersionStr && newVersionStr != "" { // Setting Metadata to a computed value plan.Metadata = types.ObjectUnknown(metadataAttrTypes()) } } } client := action.NewInstall(actionConfig) cpo, chartName, diags := chartPathOptions(&plan, meta, &client.ChartPathOptions) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } chart, path, diags := getChart(ctx, &plan, meta, chartName, cpo) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } tflog.Debug(ctx, fmt.Sprintf("%s Got chart", logID)) updated, diags := checkChartDependencies(ctx, &plan, chart, path, meta) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } else if updated { chart, err = loader.Load(path) if err != nil { resp.Diagnostics.AddError("Error loading chart", err.Error()) return } } if plan.Lint.ValueBool() { diags := resourceReleaseValidate(ctx, &plan, meta, cpo) if diags.HasError() { resp.Diagnostics.Append(diags...) return } } tflog.Debug(ctx, fmt.Sprintf("%s Release validated", logID)) if meta.ExperimentEnabled("manifest") { // 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() return } if plan.PostRender != nil { binaryPath := plan.PostRender.BinaryPath.ValueString() argsList := plan.PostRender.Args.Elements() var args []string for _, arg := range argsList { args = append(args, arg.(basetypes.StringValue).ValueString()) } pr, err := postrender.NewExec(binaryPath, args...) if err != nil { resp.Diagnostics.AddError("Error creating post-renderer", fmt.Sprintf("Could not create post-renderer: %s", err)) return } client.PostRenderer = pr } if state == nil { install := action.NewInstall(actionConfig) install.ChartPathOptions = *cpo install.DryRun = true install.DisableHooks = plan.DisableWebhooks.ValueBool() install.Wait = plan.Wait.ValueBool() install.WaitForJobs = plan.WaitForJobs.ValueBool() install.Devel = plan.Devel.ValueBool() install.DependencyUpdate = plan.DependencyUpdate.ValueBool() install.Timeout = time.Duration(plan.Timeout.ValueInt64()) * time.Second install.Namespace = plan.Namespace.ValueString() install.ReleaseName = plan.Name.ValueString() install.Atomic = plan.Atomic.ValueBool() install.SkipCRDs = plan.SkipCrds.ValueBool() install.SubNotes = plan.RenderSubchartNotes.ValueBool() install.DisableOpenAPIValidation = plan.DisableOpenapiValidation.ValueBool() install.Replace = plan.Replace.ValueBool() install.Description = plan.Description.ValueString() install.CreateNamespace = plan.CreateNamespace.ValueBool() install.PostRenderer = client.PostRenderer values, diags := getValues(ctx, &plan) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } tflog.Debug(ctx, fmt.Sprintf("%s performing dry run install", logID)) dry, err := install.Run(chart, values) if err != nil { // NOTE if the cluster is not reachable then we can't run the install // this will happen if the user has their cluster creation in the // same apply. We are catching this case here and marking manifest // as computed to avoid breaking existing configs 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() return } resp.Diagnostics.AddError("Error performing dry run install", err.Error()) return } jsonManifest, err := convertYAMLManifestToJSON(dry.Manifest) if err != nil { resp.Diagnostics.AddError("Error converting YAML manifest to JSON", err.Error()) return } valuesMap := make(map[string]string) if !plan.SetSensitive.IsNull() { var setSensitiveList []setResourceModel setSensitiveDiags := plan.SetSensitive.ElementsAs(ctx, &setSensitiveList, false) resp.Diagnostics.Append(setSensitiveDiags...) if resp.Diagnostics.HasError() { return } for _, set := range setSensitiveList { valuesMap[set.Name.ValueString()] = set.Value.ValueString() } } manifest := redactSensitiveValues(string(jsonManifest), valuesMap) plan.Manifest = types.StringValue(manifest) return } _, err = getRelease(ctx, meta, actionConfig, name) if err == errReleaseNotFound { if len(chart.Metadata.Version) > 0 { plan.Version = types.StringValue(chart.Metadata.Version) } plan.Manifest = types.StringNull() return } else if err != nil { resp.Diagnostics.AddError("Error retrieving old release for a diff", err.Error()) return } upgrade := action.NewUpgrade(actionConfig) upgrade.ChartPathOptions = *cpo upgrade.Devel = plan.Devel.ValueBool() upgrade.Namespace = plan.Namespace.ValueString() upgrade.Timeout = time.Duration(plan.Timeout.ValueInt64()) * time.Second upgrade.Wait = plan.Wait.ValueBool() upgrade.DryRun = true upgrade.DisableHooks = plan.DisableWebhooks.ValueBool() upgrade.Atomic = plan.Atomic.ValueBool() upgrade.SubNotes = plan.RenderSubchartNotes.ValueBool() upgrade.WaitForJobs = plan.WaitForJobs.ValueBool() upgrade.Force = plan.ForceUpdate.ValueBool() upgrade.ResetValues = plan.ResetValues.ValueBool() upgrade.ReuseValues = plan.ReuseValues.ValueBool() upgrade.Recreate = plan.RecreatePods.ValueBool() upgrade.MaxHistory = int(plan.MaxHistory.ValueInt64()) upgrade.CleanupOnFail = plan.CleanupOnFail.ValueBool() upgrade.Description = plan.Description.ValueString() upgrade.PostRenderer = client.PostRenderer values, diags := getValues(ctx, &plan) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } tflog.Debug(ctx, fmt.Sprintf("%s performing dry run upgrade", logID)) dry, err := upgrade.Run(name, chart, values) if err != nil && strings.Contains(err.Error(), "has no deployed releases") { if len(chart.Metadata.Version) > 0 && cpo.Version != "" { plan.Version = types.StringValue(chart.Metadata.Version) } plan.Version = types.StringNull() plan.Manifest = types.StringNull() return } else if err != nil { resp.Diagnostics.AddError("Error running dry run for a diff", err.Error()) return } jsonManifest, err := convertYAMLManifestToJSON(dry.Manifest) if err != nil { resp.Diagnostics.AddError("Error converting YAML manifest to JSON", err.Error()) return } valuesMap := make(map[string]string) if !plan.SetSensitive.IsNull() { var setSensitiveList []setResourceModel setSensitiveDiags := plan.SetSensitive.ElementsAs(ctx, &setSensitiveList, false) resp.Diagnostics.Append(setSensitiveDiags...) if resp.Diagnostics.HasError() { return } for _, set := range setSensitiveList { valuesMap[set.Name.ValueString()] = set.Value.ValueString() } } manifest := redactSensitiveValues(string(jsonManifest), valuesMap) plan.Manifest = types.StringValue(manifest) tflog.Debug(ctx, fmt.Sprintf("%s set manifest: %s", logID, jsonManifest)) } else { plan.Manifest = types.StringNull() } tflog.Debug(ctx, fmt.Sprintf("%s Done", logID)) if len(chart.Metadata.Version) > 0 { plan.Version = types.StringValue(chart.Metadata.Version) } else { plan.Version = types.StringNull() } if !config.Version.IsNull() && !config.Version.Equal(plan.Version) { if versionsEqual(config.Version.ValueString(), plan.Version.ValueString()) { plan.Version = config.Version } else { resp.Diagnostics.AddError( "Planned version is different from configured version", fmt.Sprintf(`The version in the configuration is %q but the planned version is %q. You should update the version in your configuration to %[2]q, or remove the version attribute from your configuration.`, config.Version.ValueString(), plan.Version.ValueString())) return } } resp.Plan.Set(ctx, &plan) } // TODO: write unit test, always returns true for recomputing the metadata // returns true if any metadata fields have changed func recomputeMetadata(plan HelmReleaseModel, state *HelmReleaseModel) bool { if state == nil { return true } if !plan.Chart.Equal(state.Chart) { return true } if !plan.Repository.Equal(state.Repository) { return true } if !plan.Values.Equal(state.Values) { return true } if !plan.Set.Equal(state.Set) { return true } if !plan.SetSensitive.Equal(state.SetSensitive) { return true } if !plan.SetList.Equal(state.SetList) { return true } return false } func resourceReleaseValidate(ctx context.Context, model *HelmReleaseModel, meta *Meta, cpo *action.ChartPathOptions) diag.Diagnostics { var diags diag.Diagnostics cpo, name, chartDiags := chartPathOptions(model, meta, cpo) diags.Append(chartDiags...) if diags.HasError() { diags.AddError("Malformed values", fmt.Sprintf("Chart path options error: %s", chartDiags)) return diags } values, valuesDiags := getValues(ctx, model) diags.Append(valuesDiags...) if diags.HasError() { return diags } lintDiags := lintChart(meta, name, cpo, values) if lintDiags != nil { diagnostic := diag.NewErrorDiagnostic("Lint Error", lintDiags.Error()) diags = append(diags, diagnostic) } return diags } func lintChart(m *Meta, name string, cpo *action.ChartPathOptions, values map[string]interface{}) error { path, err := cpo.LocateChart(name, m.Settings) if err != nil { return err } l := action.NewLint() result := l.Run([]string{path}, values) return resultToError(result) } func resultToError(r *action.LintResult) error { if len(r.Errors) == 0 { return nil } messages := []string{} for _, msg := range r.Messages { for _, err := range r.Errors { if err == msg.Err { messages = append(messages, fmt.Sprintf("%s: %s", msg.Path, msg.Err)) break } } } return fmt.Errorf("malformed chart or values: \n\t%s", strings.Join(messages, "\n\t")) } func (r *HelmRelease) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { var namespace, name string if req.ID != "" { tflog.Debug(ctx, "Using ID string for import") var err error namespace, name, err = parseImportIdentifier(req.ID) if err != nil { resp.Diagnostics.AddError( "Unable to parse import identifier", fmt.Sprintf("Unable to parse identifier %s: %s", req.ID, err), ) return } } else { tflog.Debug(ctx, "Using Resource Identity for Import") rid := HelmReleaseIdentityModel{ Namespace: types.StringValue("default"), } diags := req.Identity.Get(ctx, &rid) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } namespace = rid.Namespace.ValueString() name = rid.ReleaseName.ValueString() } meta := r.meta if meta == nil { resp.Diagnostics.AddError( "Meta not set", "The meta information is not set for the resource", ) return } actionConfig, err := meta.GetHelmConfiguration(ctx, namespace) if err != nil { resp.Diagnostics.AddError( "Error getting helm configuration", fmt.Sprintf("Unable to get Helm configuration for namespace %s: %s", namespace, err), ) return } release, err := getRelease(ctx, meta, actionConfig, name) if err != nil { resp.Diagnostics.AddError( "Error getting release", fmt.Sprintf("Unable to get Helm release %s: %s", name, err.Error()), ) return } var state HelmReleaseModel state.Name = types.StringValue(release.Name) state.Description = types.StringValue(release.Info.Description) state.Chart = types.StringValue(release.Chart.Metadata.Name) // Set release-specific attributes using the helper function diags := setReleaseAttributes(ctx, &state, resp.Identity, release, meta) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } state.Set = types.ListNull(types.ObjectType{ AttrTypes: map[string]attr.Type{ "name": types.StringType, "type": types.StringType, "value": types.StringType, }, }) state.SetWO = types.ListNull(types.ObjectType{ AttrTypes: map[string]attr.Type{ "name": types.StringType, "type": types.StringType, "value": types.StringType, }, }) state.SetSensitive = types.ListNull(types.ObjectType{ AttrTypes: map[string]attr.Type{ "name": types.StringType, "type": types.StringType, "value": types.StringType, }, }) state.SetList = types.ListNull(types.ObjectType{ AttrTypes: map[string]attr.Type{ "name": types.StringType, "value": types.ListType{ ElemType: types.StringType, }, }, }) state.Values = types.ListNull(types.StringType) tflog.Debug(ctx, fmt.Sprintf("Setting final state: %+v", state)) diags = resp.State.Set(ctx, &state) resp.Diagnostics.Append(diags...) if diags.HasError() { fmt.Println("DOH") tflog.Error(ctx, "Error setting final state", map[string]interface{}{ "state": state, "diagnostics": diags, }) return } // Set default attributes for key, value := range defaultAttributes { resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root(key), value)...) if resp.Diagnostics.HasError() { return } } } func parseImportIdentifier(id string) (string, string, error) { parts := strings.Split(id, "/") if len(parts) != 2 { err := errors.Errorf("Unexpected ID format (%q), expected namespace/name", id) return "", "", err } return parts[0], parts[1], nil } // returns true if any values, set_list, set, set_sensitive are unknown func valuesUnknown(plan HelmReleaseModel) bool { if plan.Values.IsUnknown() { return true } if plan.SetList.IsUnknown() { return true } if plan.Set.IsUnknown() { return true } if plan.SetSensitive.IsUnknown() { return true } return false }