ec/ecresource/deploymentresource/deployment/v2/deployment_read.go (374 lines of code) (raw):
// Licensed to Elasticsearch B.V. under one or more contributor
// license agreements. See the NOTICE file distributed with
// this work for additional information regarding copyright
// ownership. Elasticsearch B.V. licenses this file to you under
// the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package v2
import (
"context"
"errors"
"fmt"
"slices"
"strings"
"github.com/elastic/cloud-sdk-go/pkg/client/deployments"
"github.com/blang/semver"
"github.com/elastic/cloud-sdk-go/pkg/models"
"github.com/elastic/cloud-sdk-go/pkg/util/ec"
apmv2 "github.com/elastic/terraform-provider-ec/ec/ecresource/deploymentresource/apm/v2"
elasticsearchv1 "github.com/elastic/terraform-provider-ec/ec/ecresource/deploymentresource/elasticsearch/v1"
elasticsearchv2 "github.com/elastic/terraform-provider-ec/ec/ecresource/deploymentresource/elasticsearch/v2"
enterprisesearchv2 "github.com/elastic/terraform-provider-ec/ec/ecresource/deploymentresource/enterprisesearch/v2"
integrationsserverv2 "github.com/elastic/terraform-provider-ec/ec/ecresource/deploymentresource/integrationsserver/v2"
kibanav2 "github.com/elastic/terraform-provider-ec/ec/ecresource/deploymentresource/kibana/v2"
v1 "github.com/elastic/terraform-provider-ec/ec/ecresource/deploymentresource/observability/v1"
observabilityv2 "github.com/elastic/terraform-provider-ec/ec/ecresource/deploymentresource/observability/v2"
"github.com/elastic/terraform-provider-ec/ec/ecresource/deploymentresource/utils"
"github.com/elastic/terraform-provider-ec/ec/internal/converters"
"github.com/elastic/terraform-provider-ec/ec/internal/util"
"github.com/hashicorp/terraform-plugin-framework/diag"
"github.com/hashicorp/terraform-plugin-framework/tfsdk"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
)
type Deployment struct {
Id string `tfsdk:"id"`
Alias string `tfsdk:"alias"`
Version string `tfsdk:"version"`
Region string `tfsdk:"region"`
DeploymentTemplateId string `tfsdk:"deployment_template_id"`
Name string `tfsdk:"name"`
RequestId string `tfsdk:"request_id"`
ElasticsearchUsername string `tfsdk:"elasticsearch_username"`
ElasticsearchPassword string `tfsdk:"elasticsearch_password"`
ApmSecretToken *string `tfsdk:"apm_secret_token"`
TrafficFilter []string `tfsdk:"traffic_filter"`
Tags map[string]string `tfsdk:"tags"`
Elasticsearch *elasticsearchv2.Elasticsearch `tfsdk:"elasticsearch"`
Kibana *kibanav2.Kibana `tfsdk:"kibana"`
Apm *apmv2.Apm `tfsdk:"apm"`
IntegrationsServer *integrationsserverv2.IntegrationsServer `tfsdk:"integrations_server"`
EnterpriseSearch *enterprisesearchv2.EnterpriseSearch `tfsdk:"enterprise_search"`
Observability *observabilityv2.Observability `tfsdk:"observability"`
ResetElasticsearchPassword *bool `tfsdk:"reset_elasticsearch_password"`
MigrateToLatestHardware *bool `tfsdk:"migrate_to_latest_hardware"`
}
func (dep *Deployment) PersistSnapshotSource(ctx context.Context, esPlan *elasticsearchv2.ElasticsearchTF) diag.Diagnostics {
if dep == nil || dep.Elasticsearch == nil {
return nil
}
if esPlan == nil || esPlan.SnapshotSource.IsNull() || esPlan.SnapshotSource.IsUnknown() {
return nil
}
var snapshotSource *elasticsearchv1.ElasticsearchSnapshotSourceTF
if diags := tfsdk.ValueAs(ctx, esPlan.SnapshotSource, &snapshotSource); diags.HasError() {
return diags
}
dep.Elasticsearch.SnapshotSource = &elasticsearchv2.ElasticsearchSnapshotSource{
SourceElasticsearchClusterId: snapshotSource.SourceElasticsearchClusterId.ValueString(),
SnapshotName: snapshotSource.SnapshotName.ValueString(),
}
return nil
}
// Nullify Elasticsearch topologies that have zero size and are not specified in plan
func (dep *Deployment) NullifyUnusedEsTopologies(ctx context.Context, esPlan *elasticsearchv2.ElasticsearchTF) {
if dep.Elasticsearch == nil {
return
}
if esPlan == nil {
return
}
dep.Elasticsearch.HotTier = nullifyUnspecifiedZeroSizedTier(esPlan.HotContentTier, dep.Elasticsearch.HotTier)
dep.Elasticsearch.WarmTier = nullifyUnspecifiedZeroSizedTier(esPlan.WarmTier, dep.Elasticsearch.WarmTier)
dep.Elasticsearch.ColdTier = nullifyUnspecifiedZeroSizedTier(esPlan.ColdTier, dep.Elasticsearch.ColdTier)
dep.Elasticsearch.FrozenTier = nullifyUnspecifiedZeroSizedTier(esPlan.FrozenTier, dep.Elasticsearch.FrozenTier)
dep.Elasticsearch.MlTier = nullifyUnspecifiedZeroSizedTier(esPlan.MlTier, dep.Elasticsearch.MlTier)
dep.Elasticsearch.MasterTier = nullifyUnspecifiedZeroSizedTier(esPlan.MasterTier, dep.Elasticsearch.MasterTier)
dep.Elasticsearch.CoordinatingTier = nullifyUnspecifiedZeroSizedTier(esPlan.CoordinatingTier, dep.Elasticsearch.CoordinatingTier)
}
func nullifyUnspecifiedZeroSizedTier(tierPlan types.Object, tier *elasticsearchv2.ElasticsearchTopology) *elasticsearchv2.ElasticsearchTopology {
if tierPlan.IsNull() && tier != nil {
size, err := converters.ParseTopologySize(tier.Size, tier.SizeResource)
// we can ignore returning an error here - it's handled in readers
if err == nil && size != nil && size.Value != nil && *size.Value == 0 {
tier = nil
}
}
return tier
}
// SetLatestInstanceConfigInfo Sets latest instance_configuration_id and instance_configuration_version for each
// topology element, based on the migrate template request
func (dep *Deployment) SetLatestInstanceConfigInfo(migrateUpdateRequest *deployments.MigrateDeploymentTemplateOK) {
if migrateUpdateRequest == nil {
return
}
if dep.Elasticsearch != nil {
elasticsearchv2.SetLatestInstanceConfigInfo(dep.Elasticsearch.HotTier, elasticsearchv2.GetTopologyFromMigrateRequest(migrateUpdateRequest, "hot"))
elasticsearchv2.SetLatestInstanceConfigInfo(dep.Elasticsearch.WarmTier, elasticsearchv2.GetTopologyFromMigrateRequest(migrateUpdateRequest, "warm"))
elasticsearchv2.SetLatestInstanceConfigInfo(dep.Elasticsearch.ColdTier, elasticsearchv2.GetTopologyFromMigrateRequest(migrateUpdateRequest, "cold"))
elasticsearchv2.SetLatestInstanceConfigInfo(dep.Elasticsearch.FrozenTier, elasticsearchv2.GetTopologyFromMigrateRequest(migrateUpdateRequest, "frozen"))
elasticsearchv2.SetLatestInstanceConfigInfo(dep.Elasticsearch.MlTier, elasticsearchv2.GetTopologyFromMigrateRequest(migrateUpdateRequest, "ml"))
elasticsearchv2.SetLatestInstanceConfigInfo(dep.Elasticsearch.MasterTier, elasticsearchv2.GetTopologyFromMigrateRequest(migrateUpdateRequest, "master"))
elasticsearchv2.SetLatestInstanceConfigInfo(dep.Elasticsearch.CoordinatingTier, elasticsearchv2.GetTopologyFromMigrateRequest(migrateUpdateRequest, "coordinating"))
}
if migrateUpdateRequest.Payload.Resources.Apm != nil && len(migrateUpdateRequest.Payload.Resources.Apm) > 0 && len(migrateUpdateRequest.Payload.Resources.Apm[0].Plan.ClusterTopology) > 0 {
apmv2.SetLatestInstanceConfigInfo(dep.Apm, migrateUpdateRequest.Payload.Resources.Apm[0].Plan.ClusterTopology[0])
}
if migrateUpdateRequest.Payload.Resources.EnterpriseSearch != nil && len(migrateUpdateRequest.Payload.Resources.EnterpriseSearch) > 0 && len(migrateUpdateRequest.Payload.Resources.EnterpriseSearch[0].Plan.ClusterTopology) > 0 {
enterprisesearchv2.SetLatestInstanceConfigInfo(dep.EnterpriseSearch, migrateUpdateRequest.Payload.Resources.EnterpriseSearch[0].Plan.ClusterTopology[0])
}
if migrateUpdateRequest.Payload.Resources.IntegrationsServer != nil && len(migrateUpdateRequest.Payload.Resources.IntegrationsServer) > 0 && len(migrateUpdateRequest.Payload.Resources.IntegrationsServer[0].Plan.ClusterTopology) > 0 {
integrationsserverv2.SetLatestInstanceConfigInfo(dep.IntegrationsServer, migrateUpdateRequest.Payload.Resources.IntegrationsServer[0].Plan.ClusterTopology[0])
}
if migrateUpdateRequest.Payload.Resources.Kibana != nil && len(migrateUpdateRequest.Payload.Resources.Kibana) > 0 && len(migrateUpdateRequest.Payload.Resources.Kibana[0].Plan.ClusterTopology) > 0 {
kibanav2.SetLatestInstanceConfigInfo(dep.Kibana, migrateUpdateRequest.Payload.Resources.Kibana[0].Plan.ClusterTopology[0])
}
}
// SetLatestInstanceConfigInfoToCurrent Sets latest instance_configuration_id and instance_configuration_version for each
// topology element, based on the current values
func (dep *Deployment) SetLatestInstanceConfigInfoToCurrent() {
if dep.Elasticsearch != nil {
elasticsearchv2.SetLatestInstanceConfigInfoToCurrent(dep.Elasticsearch.HotTier)
elasticsearchv2.SetLatestInstanceConfigInfoToCurrent(dep.Elasticsearch.WarmTier)
elasticsearchv2.SetLatestInstanceConfigInfoToCurrent(dep.Elasticsearch.ColdTier)
elasticsearchv2.SetLatestInstanceConfigInfoToCurrent(dep.Elasticsearch.FrozenTier)
elasticsearchv2.SetLatestInstanceConfigInfoToCurrent(dep.Elasticsearch.MlTier)
elasticsearchv2.SetLatestInstanceConfigInfoToCurrent(dep.Elasticsearch.MasterTier)
elasticsearchv2.SetLatestInstanceConfigInfoToCurrent(dep.Elasticsearch.CoordinatingTier)
}
apmv2.SetLatestInstanceConfigInfoToCurrent(dep.Apm)
enterprisesearchv2.SetLatestInstanceConfigInfoToCurrent(dep.EnterpriseSearch)
integrationsserverv2.SetLatestInstanceConfigInfoToCurrent(dep.IntegrationsServer)
kibanav2.SetLatestInstanceConfigInfoToCurrent(dep.Kibana)
}
func ReadDeployment(res *models.DeploymentGetResponse, remotes *models.RemoteResources, deploymentResources []*models.DeploymentResource) (*Deployment, error) {
var dep Deployment
if res.ID == nil {
return nil, utils.MissingField("ID")
}
dep.Id = *res.ID
dep.Alias = res.Alias
if res.Name == nil {
return nil, utils.MissingField("Name")
}
dep.Name = *res.Name
if res.Metadata != nil {
dep.Tags = converters.ModelsTagsToMap(res.Metadata.Tags)
}
if res.Resources == nil {
return nil, nil
}
templateID, err := getDeploymentTemplateID(res.Resources)
if err != nil {
return nil, err
}
dep.DeploymentTemplateId = templateID
dep.Region = getRegion(res.Resources)
// We're reconciling the version and storing the lowest version of any
// of the deployment resources. This ensures that if an upgrade fails,
// the state version will be lower than the desired version, making
// retries possible. Once more resource types are added, the function
// needs to be modified to check those as well.
version, err := getLowestVersion(res.Resources)
if err != nil {
// This code path is highly unlikely, but we're bubbling up the
// error in case one of the versions isn't parseable by semver.
return nil, fmt.Errorf("failed reading deployment: %w", err)
}
dep.Version = version
dep.Elasticsearch, err = elasticsearchv2.ReadElasticsearches(res.Resources.Elasticsearch, remotes)
if err != nil {
return nil, err
}
if dep.Kibana, err = kibanav2.ReadKibanas(res.Resources.Kibana); err != nil {
return nil, err
}
if dep.Apm, err = apmv2.ReadApms(res.Resources.Apm); err != nil {
return nil, err
}
if dep.IntegrationsServer, err = integrationsserverv2.ReadIntegrationsServers(res.Resources.IntegrationsServer); err != nil {
return nil, err
}
if dep.EnterpriseSearch, err = enterprisesearchv2.ReadEnterpriseSearches(res.Resources.EnterpriseSearch); err != nil {
return nil, err
}
if dep.TrafficFilter, err = readTrafficFilters(res.Settings); err != nil {
return nil, err
}
if dep.Observability, err = observabilityv2.ReadObservability(res.Settings); err != nil {
return nil, err
}
dep.parseCredentials(deploymentResources)
return &dep, nil
}
func readTrafficFilters(in *models.DeploymentSettings) ([]string, error) {
if in == nil || in.TrafficFilterSettings == nil || len(in.TrafficFilterSettings.Rulesets) == 0 {
return nil, nil
}
var rules []string
return append(rules, in.TrafficFilterSettings.Rulesets...), nil
}
// parseCredentials parses the Create or Update response Resources populating
// credential settings in the Terraform state if the keys are found, currently
// populates the following credentials in plain text:
// * Elasticsearch username and Password
func (dep *Deployment) parseCredentials(resources []*models.DeploymentResource) {
for _, res := range resources {
if creds := res.Credentials; creds != nil {
if creds.Username != nil && *creds.Username != "" {
dep.ElasticsearchUsername = *creds.Username
}
if creds.Password != nil && *creds.Password != "" {
dep.ElasticsearchPassword = *creds.Password
}
}
if res.SecretToken != "" {
dep.ApmSecretToken = &res.SecretToken
}
}
}
func (dep *Deployment) ProcessSelfInObservability(ctx context.Context, base DeploymentTF) diag.Diagnostics {
if dep == nil || dep.Observability == nil {
return nil
}
if dep.Observability.DeploymentId == nil {
return nil
}
var baseObservability v1.ObservabilityTF
diags := base.Observability.As(ctx, &baseObservability, basetypes.ObjectAsOptions{
UnhandledNullAsEmpty: true,
UnhandledUnknownAsEmpty: true,
})
if diags.HasError() {
return diags
}
deploymentIDIsKnown := !(baseObservability.DeploymentId.IsNull() || baseObservability.DeploymentId.IsUnknown())
if deploymentIDIsKnown && baseObservability.DeploymentId.ValueString() != "self" {
return nil
}
if *dep.Observability.DeploymentId == dep.Id {
*dep.Observability.DeploymentId = "self"
}
return nil
}
func (dep *Deployment) IncludePrivateStateTrafficFilters(ctx context.Context, base DeploymentTF, privateFilters []string) diag.Diagnostics {
var baseFilters []string
diags := base.TrafficFilter.ElementsAs(ctx, &baseFilters, true)
if diags.HasError() {
return diags
}
for _, filter := range privateFilters {
if !slices.Contains(baseFilters, filter) {
baseFilters = append(baseFilters, filter)
}
}
if len(baseFilters) == 0 {
dep.TrafficFilter = baseFilters
}
intersectionFilters := []string{}
for _, filter := range dep.TrafficFilter {
if slices.Contains(baseFilters, filter) {
intersectionFilters = append(intersectionFilters, filter)
}
}
dep.TrafficFilter = intersectionFilters
return diags
}
func (dep *Deployment) SetCredentialsIfEmpty(state *DeploymentTF) {
if state == nil {
return
}
if dep.ElasticsearchPassword == "" && state.ElasticsearchPassword.ValueString() != "" {
dep.ElasticsearchPassword = state.ElasticsearchPassword.ValueString()
}
if dep.ElasticsearchUsername == "" && state.ElasticsearchUsername.ValueString() != "" {
dep.ElasticsearchUsername = state.ElasticsearchUsername.ValueString()
}
if (dep.ApmSecretToken == nil || *dep.ApmSecretToken == "") && state.ApmSecretToken.ValueString() != "" {
dep.ApmSecretToken = ec.String(state.ApmSecretToken.ValueString())
}
}
func (dep *Deployment) HasNodeTypes() bool {
if dep.Elasticsearch != nil {
for _, t := range dep.Elasticsearch.GetTopologies() {
if t.HasNodeTypes() {
return true
}
}
}
return false
}
func getLowestVersion(res *models.DeploymentResources) (string, error) {
// We're starting off with a very high version so it can be replaced.
replaceVersion := `99.99.99`
version := semver.MustParse(replaceVersion)
for _, r := range res.Elasticsearch {
if !util.IsCurrentEsPlanEmpty(r) {
v := r.Info.PlanInfo.Current.Plan.Elasticsearch.Version
if err := swapLowerVersion(&version, v); err != nil && !elasticsearchv2.IsElasticsearchStopped(r) {
return "", fmt.Errorf("elasticsearch version '%s' is not semver compliant: %w", v, err)
}
}
}
for _, r := range res.Kibana {
if !util.IsCurrentKibanaPlanEmpty(r) && !kibanav2.IsKibanaStopped(r) {
v := r.Info.PlanInfo.Current.Plan.Kibana.Version
if err := swapLowerVersion(&version, v); err != nil {
return version.String(), fmt.Errorf("kibana version '%s' is not semver compliant: %w", v, err)
}
}
}
for _, r := range res.Apm {
if !util.IsCurrentApmPlanEmpty(r) && !apmv2.IsApmStopped(r) {
v := r.Info.PlanInfo.Current.Plan.Apm.Version
if err := swapLowerVersion(&version, v); err != nil {
return version.String(), fmt.Errorf("apm version '%s' is not semver compliant: %w", v, err)
}
}
}
for _, r := range res.IntegrationsServer {
if !util.IsCurrentIntegrationsServerPlanEmpty(r) && !integrationsserverv2.IsIntegrationsServerStopped(r) {
v := r.Info.PlanInfo.Current.Plan.IntegrationsServer.Version
if err := swapLowerVersion(&version, v); err != nil {
return version.String(), fmt.Errorf("integrations_server version '%s' is not semver compliant: %w", v, err)
}
}
}
for _, r := range res.EnterpriseSearch {
if !util.IsCurrentEssPlanEmpty(r) && !enterprisesearchv2.IsEnterpriseSearchStopped(r) {
v := r.Info.PlanInfo.Current.Plan.EnterpriseSearch.Version
if err := swapLowerVersion(&version, v); err != nil {
return version.String(), fmt.Errorf("enterprise search version '%s' is not semver compliant: %w", v, err)
}
}
}
if version.String() != replaceVersion {
return version.String(), nil
}
return "", errors.New("unable to determine the lowest version for any the deployment components")
}
func swapLowerVersion(version *semver.Version, comp string) error {
if comp == "" {
return nil
}
v, err := semver.Parse(comp)
if err != nil {
return err
}
if v.LT(*version) {
*version = v
}
return nil
}
func getRegion(res *models.DeploymentResources) string {
for _, r := range res.Elasticsearch {
if r.Region != nil && *r.Region != "" {
return *r.Region
}
}
return ""
}
func getDeploymentTemplateID(res *models.DeploymentResources) (string, error) {
var deploymentTemplateID string
var foundTemplates []string
for _, esRes := range res.Elasticsearch {
if util.IsCurrentEsPlanEmpty(esRes) {
continue
}
var emptyDT = esRes.Info.PlanInfo.Current.Plan.DeploymentTemplate == nil
if emptyDT {
continue
}
if deploymentTemplateID == "" {
deploymentTemplateID = *esRes.Info.PlanInfo.Current.Plan.DeploymentTemplate.ID
}
foundTemplates = append(foundTemplates,
*esRes.Info.PlanInfo.Current.Plan.DeploymentTemplate.ID,
)
}
if deploymentTemplateID == "" {
return "", errors.New("failed to obtain the deployment template id")
}
if len(foundTemplates) > 1 {
return "", fmt.Errorf(
"there are more than 1 deployment templates specified on the deployment: \"%s\"", strings.Join(foundTemplates, ", "),
)
}
return deploymentTemplateID, nil
}