diff --git a/cmd/changes_end_change.go b/cmd/changes_end_change.go index 9ab15707..a7999380 100644 --- a/cmd/changes_end_change.go +++ b/cmd/changes_end_change.go @@ -83,7 +83,7 @@ func EndChange(ctx context.Context, ready chan bool) int { ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - changeUuid, err := getChangeUuid(ctx, oi, sdp.ChangeStatus_CHANGE_STATUS_HAPPENING, true) + changeUuid, err := getChangeUuid(ctx, oi, sdp.ChangeStatus_CHANGE_STATUS_HAPPENING, viper.GetString("ticket-link"), true) if err != nil { log.WithError(err).WithFields(lf).Error("failed to identify change") return 1 diff --git a/cmd/changes_get_change.go b/cmd/changes_get_change.go index 642ee0b0..d1de4b1b 100644 --- a/cmd/changes_get_change.go +++ b/cmd/changes_get_change.go @@ -102,7 +102,7 @@ func GetChange(ctx context.Context, ready chan bool) int { ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - changeUuid, err := getChangeUuid(ctx, oi, sdp.ChangeStatus(sdp.ChangeStatus_value[viper.GetString("status")]), true) + changeUuid, err := getChangeUuid(ctx, oi, sdp.ChangeStatus(sdp.ChangeStatus_value[viper.GetString("status")]), viper.GetString("ticket-link"), true) if err != nil { log.WithError(err).WithFields(lf).Error("failed to identify change") return 1 diff --git a/cmd/changes_manual_change.go b/cmd/changes_manual_change.go index 3e4f41c5..676abe4d 100644 --- a/cmd/changes_manual_change.go +++ b/cmd/changes_manual_change.go @@ -88,7 +88,7 @@ func ManualChange(ctx context.Context, ready chan bool) int { defer cancel() client := AuthenticatedChangesClient(ctx, oi) - changeUuid, err := getChangeUuid(ctx, oi, sdp.ChangeStatus_CHANGE_STATUS_DEFINING, false) + changeUuid, err := getChangeUuid(ctx, oi, sdp.ChangeStatus_CHANGE_STATUS_DEFINING, viper.GetString("ticket-link"),false) if err != nil { log.WithContext(ctx).WithError(err).WithFields(lf).Error("failed to searching for existing changes") return 1 diff --git a/cmd/changes_start_change.go b/cmd/changes_start_change.go index 6c342a81..4cf06f29 100644 --- a/cmd/changes_start_change.go +++ b/cmd/changes_start_change.go @@ -83,7 +83,7 @@ func StartChange(ctx context.Context, ready chan bool) int { ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - changeUuid, err := getChangeUuid(ctx, oi, sdp.ChangeStatus_CHANGE_STATUS_DEFINING, true) + changeUuid, err := getChangeUuid(ctx, oi, sdp.ChangeStatus_CHANGE_STATUS_DEFINING,viper.GetString("ticket-link"), true) if err != nil { log.WithError(err).WithFields(lf).Error("failed to identify change") return 1 diff --git a/cmd/changes_submit_plan.go b/cmd/changes_submit_plan.go index fffa152c..9cafefed 100644 --- a/cmd/changes_submit_plan.go +++ b/cmd/changes_submit_plan.go @@ -17,7 +17,6 @@ import ( "connectrpc.com/connect" "github.com/getsentry/sentry-go" "github.com/google/uuid" - "github.com/overmindtech/cli/cmd/datamaps" "github.com/overmindtech/cli/tracing" "github.com/overmindtech/sdp-go" log "github.com/sirupsen/logrus" @@ -25,7 +24,6 @@ import ( "github.com/spf13/viper" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" - "google.golang.org/protobuf/types/known/timestamppb" ) // submitPlanCmd represents the submit-plan command @@ -300,7 +298,7 @@ func isStateFile(bytes []byte) bool { return false } -func mappedItemDiffsFromPlan(ctx context.Context, fileName string, lf log.Fields) ([]*sdp.MappedItemDiff, error) { +func mappedItemDiffsFromPlanFile(ctx context.Context, fileName string, lf log.Fields) ([]*sdp.MappedItemDiff, error) { // read results from `terraform show -json ${tfplan file}` planJSON, err := os.ReadFile(fileName) if err != nil { @@ -308,211 +306,7 @@ func mappedItemDiffsFromPlan(ctx context.Context, fileName string, lf log.Fields return nil, err } - // Check that we haven't been passed a state file - if isStateFile(planJSON) { - return nil, fmt.Errorf("'%v' appears to be a state file, not a plan file", fileName) - } - - var plan Plan - err = json.Unmarshal(planJSON, &plan) - if err != nil { - return nil, fmt.Errorf("failed to parse '%v': %w", fileName, err) - } - - plannedChangeGroupsVar := plannedChangeGroups{ - supported: map[string][]*sdp.MappedItemDiff{}, - unsupported: map[string][]*sdp.MappedItemDiff{}, - } - - // for all managed resources: - for _, resourceChange := range plan.ResourceChanges { - if len(resourceChange.Change.Actions) == 0 || resourceChange.Change.Actions[0] == "no-op" || resourceChange.Mode == "data" { - // skip resources with no changes and data updates - continue - } - - itemDiff, err := itemDiffFromResourceChange(resourceChange) - if err != nil { - return nil, fmt.Errorf("failed to create item diff for resource change: %w", err) - } - - awsMappings := datamaps.AwssourceData[resourceChange.Type] - k8sMappings := datamaps.K8ssourceData[resourceChange.Type] - - mappings := append(awsMappings, k8sMappings...) - - if len(mappings) == 0 { - log.WithContext(ctx).WithFields(lf).WithField("terraform-address", resourceChange.Address).Debug("Skipping unmapped resource") - plannedChangeGroupsVar.Add(resourceChange.Type, &sdp.MappedItemDiff{ - Item: itemDiff, - MappingQuery: nil, // unmapped item has no mapping query - }) - continue - } - - for _, mapData := range mappings { - var currentResource *Resource - - // Look for the resource in the prior values first, since this is - // the *previous* state we're like to be able to find it in the - // actual infra - if plan.PriorState.Values != nil { - currentResource = plan.PriorState.Values.RootModule.DigResource(resourceChange.Address) - } - - // If we didn't find it, look in the planned values - if currentResource == nil { - currentResource = plan.PlannedValues.RootModule.DigResource(resourceChange.Address) - } - - if currentResource == nil { - log.WithContext(ctx). - WithFields(lf). - WithField("terraform-address", resourceChange.Address). - WithField("terraform-query-field", mapData.QueryField).Warn("Skipping resource without values") - continue - } - - query, ok := currentResource.AttributeValues.Dig(mapData.QueryField) - if !ok { - log.WithContext(ctx). - WithFields(lf). - WithField("terraform-address", resourceChange.Address). - WithField("terraform-query-field", mapData.QueryField).Warn("Skipping resource without query field") - continue - } - - // Create the map that variables will pull data from - dataMap := make(map[string]any) - - // Populate resource values - dataMap["values"] = currentResource.AttributeValues - - if overmindMappingsOutput, ok := plan.PlannedValues.Outputs["overmind_mappings"]; ok { - configResource := plan.Config.RootModule.DigResource(resourceChange.Address) - - if configResource == nil { - log.WithContext(ctx). - WithFields(lf). - WithField("terraform-address", resourceChange.Address). - Debug("Skipping provider mapping for resource without config") - } else { - // Look up the provider config key in the mappings - mappings := make(map[string]map[string]string) - - err = json.Unmarshal(overmindMappingsOutput.Value, &mappings) - - if err != nil { - log.WithContext(ctx). - WithFields(lf). - WithField("terraform-address", resourceChange.Address). - WithError(err). - Error("Failed to parse overmind_mappings output") - } else { - // We need to split out the module section of the name - // here. If the resource isn't in a module, the - // ProviderConfigKey will be something like - // "kubernetes", however if it's in a module it's be - // something like "module.something:kubernetes" - providerName := extractProviderNameFromConfigKey(configResource.ProviderConfigKey) - currentProviderMappings, ok := mappings[providerName] - - if ok { - log.WithContext(ctx). - WithFields(lf). - WithField("terraform-address", resourceChange.Address). - WithField("provider-config-key", configResource.ProviderConfigKey). - Debug("Found provider mappings") - - // We have mappings for this provider, so set them - // in the `provider_mapping` value - dataMap["provider_mapping"] = currentProviderMappings - } - } - } - } - - // Interpolate variables in the scope - scope, err := InterpolateScope(mapData.Scope, dataMap) - - if err != nil { - log.WithContext(ctx).WithError(err).Debugf("Could not find scope mapping variables %v, adding them will result in better results. Error: ", mapData.Scope) - scope = "*" - } - - u := uuid.New() - newQuery := &sdp.Query{ - Type: mapData.Type, - Method: mapData.Method, - Query: fmt.Sprintf("%v", query), - Scope: scope, - RecursionBehaviour: &sdp.Query_RecursionBehaviour{}, - UUID: u[:], - Deadline: timestamppb.New(time.Now().Add(60 * time.Second)), - } - - // cleanup item metadata from mapping query - if itemDiff.GetBefore() != nil { - itemDiff.Before.Type = newQuery.GetType() - if newQuery.GetScope() != "*" { - itemDiff.Before.Scope = newQuery.GetScope() - } - } - - // cleanup item metadata from mapping query - if itemDiff.GetAfter() != nil { - itemDiff.After.Type = newQuery.GetType() - if newQuery.GetScope() != "*" { - itemDiff.After.Scope = newQuery.GetScope() - } - } - - plannedChangeGroupsVar.Add(resourceChange.Type, &sdp.MappedItemDiff{ - Item: itemDiff, - MappingQuery: newQuery, - }) - - log.WithContext(ctx).WithFields(log.Fields{ - "scope": newQuery.GetScope(), - "type": newQuery.GetType(), - "query": newQuery.GetQuery(), - "method": newQuery.GetMethod().String(), - }).Debug("Mapped resource to query") - } - } - - supported := "" - numSupported := plannedChangeGroupsVar.NumSupportedChanges() - if numSupported > 0 { - supported = Green.Color(fmt.Sprintf("%v supported", numSupported)) - } - - unsupported := "" - numUnsupported := plannedChangeGroupsVar.NumUnsupportedChanges() - if numUnsupported > 0 { - unsupported = Yellow.Color(fmt.Sprintf("%v unsupported", numUnsupported)) - } - - numTotalChanges := numSupported + numUnsupported - - switch numTotalChanges { - case 0: - log.WithContext(ctx).Infof("Plan (%v) contained no changing resources.", fileName) - case 1: - log.WithContext(ctx).Infof("Plan (%v) contained one changing resource: %v %v", fileName, supported, unsupported) - default: - log.WithContext(ctx).Infof("Plan (%v) contained %v changing resources: %v %v", fileName, numTotalChanges, supported, unsupported) - } - - // Log the types - for typ, plannedChanges := range plannedChangeGroupsVar.supported { - log.WithContext(ctx).Infof(Green.Color(" ✓ %v (%v)"), typ, len(plannedChanges)) - } - for typ, plannedChanges := range plannedChangeGroupsVar.unsupported { - log.WithContext(ctx).Infof(Yellow.Color(" ✗ %v (%v)"), typ, len(plannedChanges)) - } - - return plannedChangeGroupsVar.MappedItemDiffs(), nil + return mappedItemDiffsFromPlan(ctx, planJSON, fileName, lf) } // Returns the name of the provider from the config key. If the resource isn't @@ -610,7 +404,7 @@ func SubmitPlan(ctx context.Context, files []string, ready chan bool) int { for _, f := range files { lf["file"] = f - mappedItemDiffs, err := mappedItemDiffsFromPlan(ctx, f, lf) + mappedItemDiffs, err := mappedItemDiffsFromPlanFile(ctx, f, lf) if err != nil { log.WithContext(ctx).WithError(err).WithFields(lf).Error("Error parsing terraform plan") return 1 @@ -620,7 +414,7 @@ func SubmitPlan(ctx context.Context, files []string, ready chan bool) int { delete(lf, "file") client := AuthenticatedChangesClient(ctx, oi) - changeUuid, err := getChangeUuid(ctx, oi, sdp.ChangeStatus_CHANGE_STATUS_DEFINING, false) + changeUuid, err := getChangeUuid(ctx, oi, sdp.ChangeStatus_CHANGE_STATUS_DEFINING, viper.GetString("ticket-link"), false) if err != nil { log.WithContext(ctx).WithError(err).WithFields(lf).Error("Failed searching for existing changes") return 1 diff --git a/cmd/changes_submit_plan_test.go b/cmd/changes_submit_plan_test.go index 1628baad..54ec5d7c 100644 --- a/cmd/changes_submit_plan_test.go +++ b/cmd/changes_submit_plan_test.go @@ -10,7 +10,7 @@ import ( ) func TestWithStateFile(t *testing.T) { - _, err := mappedItemDiffsFromPlan(context.Background(), "testdata/state.json", logrus.Fields{}) + _, err := mappedItemDiffsFromPlanFile(context.Background(), "testdata/state.json", logrus.Fields{}) if err == nil { t.Error("Expected error when running with state file, got none") @@ -18,7 +18,7 @@ func TestWithStateFile(t *testing.T) { } func TestMappedItemDiffsFromPlan(t *testing.T) { - mappedItemDiffs, err := mappedItemDiffsFromPlan(context.Background(), "testdata/plan.json", logrus.Fields{}) + mappedItemDiffs, err := mappedItemDiffsFromPlanFile(context.Background(), "testdata/plan.json", logrus.Fields{}) if err != nil { t.Error(err) diff --git a/cmd/root.go b/cmd/root.go index a65c8643..5af4e5dd 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -462,13 +462,12 @@ func HasScopesFlexible(claims *sdp.CustomClaims, requiredScopes []string) (bool, } // getChangeUuid returns the UUID of a change, as selected by --uuid or --change, or a state with the specified status and having --ticket-link -func getChangeUuid(ctx context.Context, oi OvermindInstance, expectedStatus sdp.ChangeStatus, errNotFound bool) (uuid.UUID, error) { +func getChangeUuid(ctx context.Context, oi OvermindInstance, expectedStatus sdp.ChangeStatus, ticketLink string, errNotFound bool) (uuid.UUID, error) { var changeUuid uuid.UUID var err error uuidString := viper.GetString("uuid") changeUrlString := viper.GetString("change") - ticketLink := viper.GetString("ticket-link") // If no arguments are specified then return an error if uuidString == "" && changeUrlString == "" && ticketLink == "" { diff --git a/cmd/terraform_plan.go b/cmd/terraform_plan.go index 900ad84e..8a83aaa7 100644 --- a/cmd/terraform_plan.go +++ b/cmd/terraform_plan.go @@ -2,6 +2,8 @@ package cmd import ( "context" + "crypto/sha256" + "encoding/json" "fmt" "os" "os/exec" @@ -10,9 +12,13 @@ import ( "syscall" "time" + "connectrpc.com/connect" "github.com/charmbracelet/huh" + "github.com/google/uuid" awssource "github.com/overmindtech/aws-source/cmd" + "github.com/overmindtech/cli/cmd/datamaps" "github.com/overmindtech/cli/tracing" + "github.com/overmindtech/sdp-go" "github.com/overmindtech/sdp-go/auth" stdlibsource "github.com/overmindtech/stdlib-source/cmd" log "github.com/sirupsen/logrus" @@ -21,6 +27,7 @@ import ( "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" "golang.org/x/oauth2" + "google.golang.org/protobuf/types/known/timestamppb" ) // terraformPlanCmd represents the `terraform plan` command @@ -83,7 +90,7 @@ func TerraformPlan(ctx context.Context, args []string, ready chan bool) int { return 1 } - ctx, token, err := ensureToken(ctx, oi, []string{"changes:write"}) + ctx, token, err := ensureToken(ctx, oi, []string{"changes:write", "request:receive"}) if err != nil { log.WithContext(ctx).WithFields(lf).WithError(err).Error("failed to authenticate") return 1 @@ -257,11 +264,365 @@ Running ` + "`" + `terraform %v` + "`" + ` return 1 } + tfPlanJsonCmd := exec.CommandContext(ctx, "terraform", "show", "-json", "overmind.plan") + tfPlanJsonCmd.Stderr = os.Stderr + + planJson, err := tfPlanJsonCmd.Output() + if err != nil { + log.WithError(err).Error("failed to convert terraform plan to JSON") + return 1 + } + + plannedChanges, err := mappedItemDiffsFromPlan(ctx, planJson, "overmind.plan", lf) + if err != nil { + log.WithContext(ctx).WithError(err).WithFields(lf).Error("Error parsing terraform plan") + return 1 + } + + ticketLink := viper.GetString("ticket-link") + if ticketLink == "" { + h := sha256.New() + h.Write(planJson) + ticketLink = fmt.Sprintf("tfplan://{SHA256}%x", h.Sum(nil)) + } + client := AuthenticatedChangesClient(ctx, oi) + changeUuid, err := getChangeUuid(ctx, oi, sdp.ChangeStatus_CHANGE_STATUS_DEFINING, ticketLink, false) + if err != nil { + log.WithContext(ctx).WithError(err).WithFields(lf).Error("Failed searching for existing changes") + return 1 + } + + title := changeTitle(viper.GetString("title")) + tfPlanOutput := tryLoadText(ctx, viper.GetString("terraform-plan-output")) + codeChangesOutput := tryLoadText(ctx, viper.GetString("code-changes-diff")) + + if changeUuid == uuid.Nil { + log.WithContext(ctx).WithFields(lf).Debug("Creating a new change") + createResponse, err := client.CreateChange(ctx, &connect.Request[sdp.CreateChangeRequest]{ + Msg: &sdp.CreateChangeRequest{ + Properties: &sdp.ChangeProperties{ + Title: title, + Description: viper.GetString("description"), + TicketLink: ticketLink, + Owner: viper.GetString("owner"), + // CcEmails: viper.GetString("cc-emails"), + RawPlan: tfPlanOutput, + CodeChanges: codeChangesOutput, + }, + }, + }) + if err != nil { + log.WithContext(ctx).WithError(err).WithFields(lf).Error("Failed to create change") + return 1 + } + + maybeChangeUuid := createResponse.Msg.GetChange().GetMetadata().GetUUIDParsed() + if maybeChangeUuid == nil { + log.WithContext(ctx).WithError(err).WithFields(lf).Error("Failed to read change id") + return 1 + } + + changeUuid = *maybeChangeUuid + lf["change"] = changeUuid + log.WithContext(ctx).WithFields(lf).Info("Created a new change") + } else { + lf["change"] = changeUuid + log.WithContext(ctx).WithFields(lf).Debug("Updating an existing change") + + _, err := client.UpdateChange(ctx, &connect.Request[sdp.UpdateChangeRequest]{ + Msg: &sdp.UpdateChangeRequest{ + UUID: changeUuid[:], + Properties: &sdp.ChangeProperties{ + Title: title, + Description: viper.GetString("description"), + TicketLink: ticketLink, + Owner: viper.GetString("owner"), + // CcEmails: viper.GetString("cc-emails"), + RawPlan: tfPlanOutput, + CodeChanges: codeChangesOutput, + }, + }, + }) + if err != nil { + log.WithContext(ctx).WithError(err).WithFields(lf).Error("Failed to update change") + return 1 + } + + log.WithContext(ctx).WithFields(lf).Info("Re-using change") + } + + resultStream, err := client.UpdatePlannedChanges(ctx, &connect.Request[sdp.UpdatePlannedChangesRequest]{ + Msg: &sdp.UpdatePlannedChangesRequest{ + ChangeUUID: changeUuid[:], + ChangingItems: plannedChanges, + }, + }) + if err != nil { + log.WithContext(ctx).WithFields(lf).WithError(err).Error("Failed to update planned changes") + return 1 + } + + last_log := time.Now() + first_log := true + for resultStream.Receive() { + msg := resultStream.Msg() + + // log the first message and at most every 250ms during discovery + // to avoid spanning the cli output + time_since_last_log := time.Since(last_log) + if first_log || msg.GetState() != sdp.CalculateBlastRadiusResponse_STATE_DISCOVERING || time_since_last_log > 250*time.Millisecond { + log.WithContext(ctx).WithFields(lf).WithField("msg", msg).Info("Status update") + last_log = time.Now() + first_log = false + } + } + if resultStream.Err() != nil { + log.WithContext(ctx).WithFields(lf).WithError(resultStream.Err()).Error("Error streaming results") + return 1 + } + + frontend, _ := strings.CutSuffix(viper.GetString("frontend"), "/") + changeUrl := fmt.Sprintf("%v/changes/%v/blast-radius", frontend, changeUuid) + log.WithContext(ctx).WithFields(lf).WithField("change-url", changeUrl).Info("Change ready") + fmt.Println(changeUrl) + + fetchResponse, err := client.GetChange(ctx, &connect.Request[sdp.GetChangeRequest]{ + Msg: &sdp.GetChangeRequest{ + UUID: changeUuid[:], + }, + }) + if err != nil { + log.WithContext(ctx).WithFields(lf).WithError(err).Error("Failed to get updated change") + return 1 + } + + for _, a := range fetchResponse.Msg.GetChange().GetProperties().GetAffectedAppsUUID() { + appUuid, err := uuid.FromBytes(a) + if err != nil { + log.WithContext(ctx).WithFields(lf).WithError(err).WithField("app", a).Error("Received invalid app uuid") + continue + } + log.WithContext(ctx).WithFields(lf).WithFields(log.Fields{ + "change-url": changeUrl, + "app": appUuid, + "app-url": fmt.Sprintf("%v/apps/%v", frontend, appUuid), + }).Info("Affected app") + } + return 0 } +func mappedItemDiffsFromPlan(ctx context.Context, planJson []byte, fileName string, lf log.Fields) ([]*sdp.MappedItemDiff, error) { + // Check that we haven't been passed a state file + if isStateFile(planJson) { + return nil, fmt.Errorf("'%v' appears to be a state file, not a plan file", fileName) + } + + var plan Plan + err := json.Unmarshal(planJson, &plan) + if err != nil { + return nil, fmt.Errorf("failed to parse '%v': %w", fileName, err) + } + + plannedChangeGroupsVar := plannedChangeGroups{ + supported: map[string][]*sdp.MappedItemDiff{}, + unsupported: map[string][]*sdp.MappedItemDiff{}, + } + + // for all managed resources: + for _, resourceChange := range plan.ResourceChanges { + if len(resourceChange.Change.Actions) == 0 || resourceChange.Change.Actions[0] == "no-op" || resourceChange.Mode == "data" { + // skip resources with no changes and data updates + continue + } + + itemDiff, err := itemDiffFromResourceChange(resourceChange) + if err != nil { + return nil, fmt.Errorf("failed to create item diff for resource change: %w", err) + } + + awsMappings := datamaps.AwssourceData[resourceChange.Type] + k8sMappings := datamaps.K8ssourceData[resourceChange.Type] + + mappings := append(awsMappings, k8sMappings...) + + if len(mappings) == 0 { + log.WithContext(ctx).WithFields(lf).WithField("terraform-address", resourceChange.Address).Debug("Skipping unmapped resource") + plannedChangeGroupsVar.Add(resourceChange.Type, &sdp.MappedItemDiff{ + Item: itemDiff, + MappingQuery: nil, // unmapped item has no mapping query + }) + continue + } + + for _, mapData := range mappings { + var currentResource *Resource + + // Look for the resource in the prior values first, since this is + // the *previous* state we're like to be able to find it in the + // actual infra + if plan.PriorState.Values != nil { + currentResource = plan.PriorState.Values.RootModule.DigResource(resourceChange.Address) + } + + // If we didn't find it, look in the planned values + if currentResource == nil { + currentResource = plan.PlannedValues.RootModule.DigResource(resourceChange.Address) + } + + if currentResource == nil { + log.WithContext(ctx). + WithFields(lf). + WithField("terraform-address", resourceChange.Address). + WithField("terraform-query-field", mapData.QueryField).Warn("Skipping resource without values") + continue + } + + query, ok := currentResource.AttributeValues.Dig(mapData.QueryField) + if !ok { + log.WithContext(ctx). + WithFields(lf). + WithField("terraform-address", resourceChange.Address). + WithField("terraform-query-field", mapData.QueryField).Warn("Skipping resource without query field") + continue + } + + // Create the map that variables will pull data from + dataMap := make(map[string]any) + + // Populate resource values + dataMap["values"] = currentResource.AttributeValues + + if overmindMappingsOutput, ok := plan.PlannedValues.Outputs["overmind_mappings"]; ok { + configResource := plan.Config.RootModule.DigResource(resourceChange.Address) + + if configResource == nil { + log.WithContext(ctx). + WithFields(lf). + WithField("terraform-address", resourceChange.Address). + Debug("Skipping provider mapping for resource without config") + } else { + // Look up the provider config key in the mappings + mappings := make(map[string]map[string]string) + + err = json.Unmarshal(overmindMappingsOutput.Value, &mappings) + + if err != nil { + log.WithContext(ctx). + WithFields(lf). + WithField("terraform-address", resourceChange.Address). + WithError(err). + Error("Failed to parse overmind_mappings output") + } else { + // We need to split out the module section of the name + // here. If the resource isn't in a module, the + // ProviderConfigKey will be something like + // "kubernetes", however if it's in a module it's be + // something like "module.something:kubernetes" + providerName := extractProviderNameFromConfigKey(configResource.ProviderConfigKey) + currentProviderMappings, ok := mappings[providerName] + + if ok { + log.WithContext(ctx). + WithFields(lf). + WithField("terraform-address", resourceChange.Address). + WithField("provider-config-key", configResource.ProviderConfigKey). + Debug("Found provider mappings") + + // We have mappings for this provider, so set them + // in the `provider_mapping` value + dataMap["provider_mapping"] = currentProviderMappings + } + } + } + } + + // Interpolate variables in the scope + scope, err := InterpolateScope(mapData.Scope, dataMap) + + if err != nil { + log.WithContext(ctx).WithError(err).Debugf("Could not find scope mapping variables %v, adding them will result in better results. Error: ", mapData.Scope) + scope = "*" + } + + u := uuid.New() + newQuery := &sdp.Query{ + Type: mapData.Type, + Method: mapData.Method, + Query: fmt.Sprintf("%v", query), + Scope: scope, + RecursionBehaviour: &sdp.Query_RecursionBehaviour{}, + UUID: u[:], + Deadline: timestamppb.New(time.Now().Add(60 * time.Second)), + } + + // cleanup item metadata from mapping query + if itemDiff.GetBefore() != nil { + itemDiff.Before.Type = newQuery.GetType() + if newQuery.GetScope() != "*" { + itemDiff.Before.Scope = newQuery.GetScope() + } + } + + // cleanup item metadata from mapping query + if itemDiff.GetAfter() != nil { + itemDiff.After.Type = newQuery.GetType() + if newQuery.GetScope() != "*" { + itemDiff.After.Scope = newQuery.GetScope() + } + } + + plannedChangeGroupsVar.Add(resourceChange.Type, &sdp.MappedItemDiff{ + Item: itemDiff, + MappingQuery: newQuery, + }) + + log.WithContext(ctx).WithFields(log.Fields{ + "scope": newQuery.GetScope(), + "type": newQuery.GetType(), + "query": newQuery.GetQuery(), + "method": newQuery.GetMethod().String(), + }).Debug("Mapped resource to query") + } + } + + supported := "" + numSupported := plannedChangeGroupsVar.NumSupportedChanges() + if numSupported > 0 { + supported = Green.Color(fmt.Sprintf("%v supported", numSupported)) + } + + unsupported := "" + numUnsupported := plannedChangeGroupsVar.NumUnsupportedChanges() + if numUnsupported > 0 { + unsupported = Yellow.Color(fmt.Sprintf("%v unsupported", numUnsupported)) + } + + numTotalChanges := numSupported + numUnsupported + + switch numTotalChanges { + case 0: + log.WithContext(ctx).Infof("Plan (%v) contained no changing resources.", fileName) + case 1: + log.WithContext(ctx).Infof("Plan (%v) contained one changing resource: %v %v", fileName, supported, unsupported) + default: + log.WithContext(ctx).Infof("Plan (%v) contained %v changing resources: %v %v", fileName, numTotalChanges, supported, unsupported) + } + + // Log the types + for typ, plannedChanges := range plannedChangeGroupsVar.supported { + log.WithContext(ctx).Infof(Green.Color(" ✓ %v (%v)"), typ, len(plannedChanges)) + } + for typ, plannedChanges := range plannedChangeGroupsVar.unsupported { + log.WithContext(ctx).Infof(Yellow.Color(" ✗ %v (%v)"), typ, len(plannedChanges)) + } + + return plannedChangeGroupsVar.MappedItemDiffs(), nil +} + func init() { terraformCmd.AddCommand(terraformPlanCmd) addAPIFlags(terraformPlanCmd) + addChangeUuidFlags(terraformPlanCmd) }