internal/langserver/handlers/command/aztfmigrate_command.go (355 lines of code) (raw):

package command import ( "context" "encoding/json" "fmt" "log" "net/url" "os" "path" "path/filepath" "runtime" "strings" context2 "github.com/Azure/azapi-lsp/internal/context" lsctx "github.com/Azure/azapi-lsp/internal/context" ilsp "github.com/Azure/azapi-lsp/internal/lsp" lsp "github.com/Azure/azapi-lsp/internal/protocol" "github.com/Azure/azapi-lsp/internal/utils" "github.com/Azure/aztfmigrate/azurerm" "github.com/Azure/aztfmigrate/cmd" "github.com/Azure/aztfmigrate/helper" "github.com/Azure/aztfmigrate/tf" "github.com/Azure/aztfmigrate/types" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/hashicorp/hcl/v2/hclwrite" "github.com/hashicorp/terraform-exec/tfexec" ) const tempFolderName = "aztfmigrate_temp" const importFileName = "imports.tf" const planFileName = "planfile" type AztfMigrateCommand struct { } var _ CommandHandler = &AztfMigrateCommand{} func (c AztfMigrateCommand) Handle(ctx context.Context, arguments []json.RawMessage) (interface{}, error) { var params lsp.CodeActionParams if len(arguments) != 0 { err := json.Unmarshal(arguments[0], &params) if err != nil { return nil, fmt.Errorf("failed to unmarshal arguments: %w", err) } } telemetrySender, err := context2.Telemetry(ctx) if err != nil { return nil, err } clientCaller, err := context2.ClientCaller(ctx) if err != nil { return nil, err } clientNotifier, err := context2.ClientNotifier(ctx) if err != nil { return nil, err } telemetrySender.SendEvent(ctx, "aztfmigrate", map[string]interface{}{ "status": "started", }) reportProgress(ctx, "Parsing Terraform configurations...", 0) defer reportProgress(ctx, "Migration completed.", 100) fs, err := lsctx.DocumentStorage(ctx) if err != nil { return nil, err } doc, err := fs.GetDocument(ilsp.FileHandlerFromDocumentURI(params.TextDocument.URI)) if err != nil { return nil, err } // creating temp workspace workingDirectory := getWorkingDirectory(string(params.TextDocument.URI), runtime.GOOS) tempDir := filepath.Join(workingDirectory, tempFolderName) if err := os.MkdirAll(tempDir, 0750); err != nil { return nil, fmt.Errorf("failed to create temp workspace %q, please check the permission: %w", tempDir, err) } defer func() { err := os.RemoveAll(path.Join(tempDir, "terraform.tfstate")) if err != nil { log.Printf("[ERROR] removing temp workspace %q: %+v", tempDir, err) } }() startDocPos := lsp.TextDocumentPositionParams{ TextDocument: params.TextDocument, Position: params.Range.Start, } startPos, err := ilsp.FilePositionFromDocumentPosition(startDocPos, doc) if err != nil { return nil, err } endDocPos := lsp.TextDocumentPositionParams{ TextDocument: params.TextDocument, Position: params.Range.End, } endPos, err := ilsp.FilePositionFromDocumentPosition(endDocPos, doc) if err != nil { return nil, err } data, err := doc.Text() if err != nil { return nil, err } // parsing the document syntaxDoc, diags := hclsyntax.ParseConfig(data, "", hcl.InitialPos) if diags.HasErrors() { return nil, fmt.Errorf("parsing the HCL file: %s", diags.Error()) } writeDoc, diags := hclwrite.ParseConfig(data, "", hcl.InitialPos) if diags.HasErrors() { return nil, fmt.Errorf("parsing the HCL file: %s", diags.Error()) } syntaxBlockMap := map[string]*hclsyntax.Block{} writeBlockMap := map[string]*hclwrite.Block{} body, ok := syntaxDoc.Body.(*hclsyntax.Body) if !ok { return nil, fmt.Errorf("failed to parse HCL syntax") } addresses := make([]string, 0) for _, block := range body.Blocks { if startPos.Position().Byte <= block.Range().Start.Byte && block.Range().End.Byte <= endPos.Position().Byte { if block.Type != "resource" { continue } address := strings.Join(block.Labels, ".") addresses = append(addresses, address) syntaxBlockMap[address] = block } } for _, block := range writeDoc.Body().Blocks() { address := strings.Join(block.Labels(), ".") if _, ok := syntaxBlockMap[address]; ok { writeBlockMap[address] = block } } reportProgress(ctx, "Running Terraform plan...", 10) // running terraform plan terraform, err := tf.NewTerraform(workingDirectory, false) if err != nil { return nil, err } // AzureRM provider will honor env.var "AZURE_HTTP_USER_AGENT" when constructing for HTTP "User-Agent" header. // #nosec G104 _ = os.Setenv("AZURE_HTTP_USER_AGENT", "aztfmigrate-vscode") // The following env.vars are used to disable enhanced validation and skip provider registration, to speed up the process. // #nosec G104 _ = os.Setenv("ARM_PROVIDER_ENHANCED_VALIDATION", "false") // #nosec G104 _ = os.Setenv("ARM_SKIP_PROVIDER_REGISTRATION", "true") options := make([]tfexec.PlanOption, 0) for address := range syntaxBlockMap { options = append(options, tfexec.Target(address)) } planfile := path.Join(tempDir, planFileName) options = append(options, tfexec.Out(planfile)) _, err = terraform.GetExec().Plan(ctx, options...) if err != nil { _ = clientNotifier.Notify(ctx, "window/showMessage", lsp.ShowMessageParams{ Type: lsp.Error, Message: fmt.Sprintf("Failed to run Terraform plan: %v", err), }) return nil, nil } plan, err := terraform.GetExec().ShowPlanFile(ctx, planfile) if err != nil { return nil, err } allResources := types.ListResourcesFromPlan(plan) resources := make([]types.AzureResource, 0) srcAzapiTypes := make(map[string]bool) srcAzurermTypes := make(map[string]bool) for _, r := range allResources { if syntaxBlockMap[r.OldAddress(nil)] == nil { continue } switch resource := r.(type) { case *types.AzapiResource: if len(resource.Instances) == 0 { continue } resourceId := resource.Instances[0].ResourceId resourceTypes, exact, err := azurerm.GetAzureRMResourceType(resourceId) if err != nil { log.Printf("failed to get resource type for %s: %v", resourceId, err) continue } if len(resourceTypes) == 0 { log.Printf("failed to get resource type for %s", resourceId) continue } if !exact { log.Printf("multiple resource types found for %s: %v", resourceId, resourceTypes) } resource.ResourceType = resourceTypes[0] resources = append(resources, resource) azureResourceType := utils.GetResourceType(resourceId) srcAzapiTypes[azureResourceType] = true case *types.AzurermResource: if len(resource.Instances) == 0 { continue } resources = append(resources, resource) srcAzurermTypes[resource.OldResourceType] = true } } if len(resources) == 0 { _ = clientNotifier.Notify(ctx, "window/showMessage", lsp.ShowMessageParams{ Type: lsp.Error, Message: "No resources found in the selected range. Please check whether the target resource is deployed.", }) return nil, nil } tempTerraform, err := tf.NewTerraform(tempDir, false) if err != nil { return nil, err } if err = os.WriteFile(filepath.Join(tempDir, importFileName), []byte(cmd.ImportConfig(resources, helper.FindHclBlock(workingDirectory, "terraform", nil))), 0600); err != nil { return nil, err } for index, r := range resources { // #nosec G115 reportProgress(ctx, fmt.Sprintf("Migrating resource %d/%d...", index+1, len(resources)), 40+uint32(50.0*index/len(resources))) if err := r.GenerateNewConfig(tempTerraform); err != nil { log.Printf("[ERROR] %+v", err) _ = clientNotifier.Notify(ctx, "window/showMessage", lsp.ShowMessageParams{ Type: lsp.Error, Message: fmt.Sprintf("Failed to generate new config for %s: %v", r.OldAddress(nil), err), }) } } reportProgress(ctx, "Updating Terraform configurations...", 90) // migrate depends_on, lifecycle, provisioner for _, r := range resources { existingBlock := writeBlockMap[r.OldAddress(nil)] if existingBlock == nil { continue } migratedBlock := r.MigratedBlock() if attr := existingBlock.Body().GetAttribute("depends_on"); attr != nil { migratedBlock.Body().SetAttributeRaw("depends_on", attr.Expr().BuildTokens(nil)) } for _, block := range existingBlock.Body().Blocks() { if block.Type() == "lifecycle" || block.Type() == "provisioner" { migratedBlock.Body().AppendBlock(block) } } } // update config emptyFile := hclwrite.NewEmptyFile() outputs := make([]types.Output, 0) resourcesMap := make(map[string]types.AzureResource) for _, r := range resources { if r.IsMigrated() { outputs = append(outputs, r.Outputs()...) } resourcesMap[r.OldAddress(nil)] = r } for _, addr := range addresses { r := resourcesMap[addr] if r == nil { emptyFile.Body().AppendBlock(writeBlockMap[addr]) emptyFile.Body().AppendNewline() continue } if writeBlockMap[r.OldAddress(nil)] == nil { continue } if !r.IsMigrated() { emptyFile.Body().AppendBlock(writeBlockMap[r.OldAddress(nil)]) emptyFile.Body().AppendNewline() continue } emptyFile.Body().AppendUnstructuredTokens(types.CommentOutBlock(writeBlockMap[r.OldAddress(nil)])) emptyFile.Body().AppendNewline() for _, blockToAdd := range r.StateUpdateBlocks() { if blockToAdd == nil { continue } emptyFile.Body().AppendBlock(blockToAdd) emptyFile.Body().AppendNewline() } if migratedBlock := r.MigratedBlock(); migratedBlock != nil { types.ReplaceOutputs(migratedBlock, outputs) emptyFile.Body().AppendBlock(migratedBlock) emptyFile.Body().AppendNewline() } } _, _ = clientCaller.Callback(ctx, "workspace/applyEdit", lsp.ApplyWorkspaceEditParams{ Label: "Update config", Edit: lsp.WorkspaceEdit{ Changes: map[string][]lsp.TextEdit{ string(params.TextDocument.URI): { { Range: params.Range, NewText: string(hclwrite.Format(emptyFile.Bytes())), }, }, }, }, }) azapiTypes := make([]string, 0) for t := range srcAzapiTypes { azapiTypes = append(azapiTypes, t) } azurermTypes := make([]string, 0) for t := range srcAzurermTypes { azurermTypes = append(azurermTypes, t) } telemetrySender.SendEvent(ctx, "aztfmigrate", map[string]interface{}{ "status": "completed", "count": fmt.Sprintf("%d", len(resources)), "azapi": strings.Join(azapiTypes, ","), "azurerm": strings.Join(azurermTypes, ","), }) return nil, nil } func reportProgress(ctx context.Context, message string, percentage uint32) { clientCaller, err := context2.ClientCaller(ctx) if err != nil { log.Printf("[ERROR] failed to get client caller: %+v", err) return } clientNotifier, err := context2.ClientNotifier(ctx) if err != nil { log.Printf("[ERROR] failed to get client notifier: %+v", err) return } switch percentage { case 0: _, _ = clientCaller.Callback(ctx, "window/workDoneProgress/create", lsp.WorkDoneProgressCreateParams{ Token: "aztfmigrate", }) _ = clientNotifier.Notify(ctx, "$/progress", map[string]interface{}{ "token": "aztfmigrate", "value": lsp.WorkDoneProgressBegin{ Kind: "begin", Title: "Azure providers migration", Cancellable: false, Message: message, Percentage: 0, }, }) case 100: _ = clientNotifier.Notify(ctx, "$/progress", map[string]interface{}{ "token": "aztfmigrate", "value": lsp.WorkDoneProgressEnd{ Kind: "end", Message: message, }, }) default: _ = clientNotifier.Notify(ctx, "$/progress", map[string]interface{}{ "token": "aztfmigrate", "value": lsp.WorkDoneProgressReport{ Kind: "report", Cancellable: false, Message: message, Percentage: percentage, }, }) } } func getWorkingDirectory(uri string, os string) string { workingDirectory := path.Dir(strings.TrimPrefix(uri, "file://")) if os == "windows" { workingDirectory, _ = url.QueryUnescape(workingDirectory) workingDirectory = strings.ReplaceAll(workingDirectory, "/", "\\") workingDirectory = strings.TrimPrefix(workingDirectory, "\\") } return workingDirectory }