cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go (564 lines of code) (raw):

// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. package terraform import ( "context" "encoding/json" "fmt" "log" "maps" "os" "path/filepath" "slices" "strings" "github.com/azure/azure-dev/cli/azd/internal" "github.com/azure/azure-dev/cli/azd/pkg/environment" "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" "github.com/azure/azure-dev/cli/azd/pkg/input" "github.com/azure/azure-dev/cli/azd/pkg/osutil" "github.com/azure/azure-dev/cli/azd/pkg/prompt" "github.com/azure/azure-dev/cli/azd/pkg/tools" "github.com/azure/azure-dev/cli/azd/pkg/tools/terraform" "github.com/drone/envsubst" "go.opentelemetry.io/otel/trace" ) const ( defaultModule = "main" defaultPath = "infra" ) // TerraformProvider exposes infrastructure provisioning using Azure Terraform templates type TerraformProvider struct { envManager environment.Manager env *environment.Environment prompters prompt.Prompter console input.Console cli *terraform.Cli curPrincipal provisioning.CurrentPrincipalIdProvider projectPath string options provisioning.Options } type terraformDeploymentDetails struct { ParameterFilePath string PlanFilePath string localStateFilePath string } // Name gets the name of the infra provider func (t *TerraformProvider) Name() string { return "Terraform" } func (t *TerraformProvider) RequiredExternalTools() []tools.ExternalTool { return []tools.ExternalTool{t.cli} } // NewTerraformProvider creates a new instance of a Terraform Infra provider func NewTerraformProvider( cli *terraform.Cli, envManager environment.Manager, env *environment.Environment, console input.Console, curPrincipal provisioning.CurrentPrincipalIdProvider, prompters prompt.Prompter, ) provisioning.Provider { provider := &TerraformProvider{ envManager: envManager, env: env, console: console, cli: cli, curPrincipal: curPrincipal, prompters: prompters, } return provider } func (t *TerraformProvider) Initialize(ctx context.Context, projectPath string, options provisioning.Options) error { t.projectPath = projectPath t.options = options if t.options.Module == "" { t.options.Module = defaultModule } if t.options.Path == "" { t.options.Path = defaultPath } requiredTools := t.RequiredExternalTools() if err := tools.EnsureInstalled(ctx, requiredTools...); err != nil { return err } if err := t.EnsureEnv(ctx); err != nil { return err } envVars := []string{ // Sets the terraform data directory env var that will get set on all terraform CLI commands fmt.Sprintf("TF_DATA_DIR=%s", t.dataDirPath()), // Required when using service principal login fmt.Sprintf("ARM_TENANT_ID=%s", os.Getenv("ARM_TENANT_ID")), fmt.Sprintf("ARM_SUBSCRIPTION_ID=%s", t.env.GetSubscriptionId()), fmt.Sprintf("ARM_CLIENT_ID=%s", os.Getenv("ARM_CLIENT_ID")), fmt.Sprintf("ARM_CLIENT_SECRET=%s", os.Getenv("ARM_CLIENT_SECRET")), // Include azd in user agent fmt.Sprintf("TF_APPEND_USER_AGENT=%s", internal.UserAgent()), } spanCtx := trace.SpanContextFromContext(ctx) if spanCtx.HasTraceID() { envVars = append(envVars, fmt.Sprintf("ARM_CORRELATION_REQUEST_ID=%s", spanCtx.TraceID().String())) } t.cli.SetEnv(envVars) return nil } // EnsureEnv ensures that the environment is in a provision-ready state with required values set, prompting the user if // values are unset. // // An environment is considered to be in a provision-ready state if it contains both an AZURE_SUBSCRIPTION_ID and // AZURE_LOCATION value. func (t *TerraformProvider) EnsureEnv(ctx context.Context) error { return provisioning.EnsureSubscriptionAndLocation( ctx, t.envManager, t.env, t.prompters, provisioning.EnsureSubscriptionAndLocationOptions{}, ) } // Previews the infrastructure through terraform plan func (t *TerraformProvider) plan(ctx context.Context) (*provisioning.Deployment, *terraformDeploymentDetails, error) { isRemoteBackendConfig, err := t.isRemoteBackendConfig() if err != nil { return nil, nil, fmt.Errorf("reading backend config: %w", err) } modulePath := t.modulePath() initRes, err := t.init(ctx, isRemoteBackendConfig) if err != nil { return nil, nil, fmt.Errorf("terraform init failed: %s , err: %w", initRes, err) } err = t.createInputParametersFile(ctx, t.parametersTemplateFilePath(), t.parametersFilePath()) if err != nil { return nil, nil, fmt.Errorf("creating parameters file: %w", err) } validated, err := t.cli.Validate(ctx, modulePath) if err != nil { return nil, nil, fmt.Errorf("terraform validate failed: %s, err %w", validated, err) } planArgs := t.createPlanArgs(isRemoteBackendConfig) runResult, err := t.cli.Plan(ctx, modulePath, t.planFilePath(), planArgs...) if err != nil { return nil, nil, fmt.Errorf("terraform plan failed:%s err %w", runResult, err) } //create deployment plan deployment, err := t.createDeployment(ctx) if err != nil { return nil, nil, fmt.Errorf("create terraform template failed: %w", err) } deploymentDetails := terraformDeploymentDetails{ ParameterFilePath: t.parametersFilePath(), PlanFilePath: t.planFilePath(), } if !isRemoteBackendConfig { deploymentDetails.localStateFilePath = t.localStateFilePath() } return deployment, &deploymentDetails, nil } // Deploy the infrastructure within the specified template through terraform apply func (t *TerraformProvider) Deploy(ctx context.Context) (*provisioning.DeployResult, error) { t.console.Message(ctx, "Locating plan file...") modulePath := t.modulePath() deployment, terraformDeploymentData, err := t.plan(ctx) if err != nil { return nil, err } isRemoteBackendConfig, err := t.isRemoteBackendConfig() if err != nil { return nil, fmt.Errorf("reading backend config: %w", err) } applyArgs, err := t.createApplyArgs(isRemoteBackendConfig, *terraformDeploymentData) if err != nil { return nil, err } runResult, err := t.cli.Apply(ctx, modulePath, applyArgs...) if err != nil { return nil, fmt.Errorf("template Deploy failed: %s , err:%w", runResult, err) } // Set the deployment result outputs, err := t.createOutputParameters(ctx, modulePath, isRemoteBackendConfig) if err != nil { return nil, fmt.Errorf("create terraform template failed: %w", err) } deployment.Outputs = outputs return &provisioning.DeployResult{ Deployment: deployment, }, nil } func (t *TerraformProvider) Preview(ctx context.Context) (*provisioning.DeployPreviewResult, error) { // terraform uses plan() to display the what-if output // no changes are added to the properties _, _, err := t.plan(ctx) if err != nil { return nil, err } return &provisioning.DeployPreviewResult{ Preview: &provisioning.DeploymentPreview{ Status: "done", Properties: &provisioning.DeploymentPreviewProperties{}, }, }, nil } // Destroys the specified deployment through terraform destroy func (t *TerraformProvider) Destroy( ctx context.Context, options provisioning.DestroyOptions, ) (*provisioning.DestroyResult, error) { isRemoteBackendConfig, err := t.isRemoteBackendConfig() if err != nil { return nil, fmt.Errorf("reading backend config: %w", err) } t.console.Message(ctx, "Locating parameters file...") err = t.ensureParametersFile(ctx) if err != nil { return nil, err } modulePath := t.modulePath() //load the deployment result outputs, err := t.createOutputParameters(ctx, modulePath, isRemoteBackendConfig) if err != nil { return nil, fmt.Errorf("load terraform template output failed: %w", err) } t.console.Message(ctx, "Deleting terraform deployment...") // terraform doesn't use the `t.console`, we must ensure no spinner is running before calling Destroy // as it could be an interactive operation if it needs confirmation t.console.StopSpinner(ctx, "", input.Step) destroyArgs := t.createDestroyArgs(isRemoteBackendConfig, options.Force()) runResult, err := t.cli.Destroy(ctx, modulePath, destroyArgs...) if err != nil { return nil, fmt.Errorf("template Deploy failed: %s, err: %w", runResult, err) } return &provisioning.DestroyResult{ InvalidatedEnvKeys: slices.Collect(maps.Keys(outputs)), }, nil } func (t *TerraformProvider) State( ctx context.Context, options *provisioning.StateOptions, ) (*provisioning.StateResult, error) { isRemoteBackendConfig, err := t.isRemoteBackendConfig() if err != nil { return nil, fmt.Errorf("reading backend config: %w", err) } t.console.Message(ctx, "Retrieving terraform state...") modulePath := t.modulePath() terraformState, err := t.showCurrentState(ctx, modulePath, isRemoteBackendConfig) if err != nil { return nil, fmt.Errorf("fetching terraform state failed: %w", err) } state := provisioning.State{} state.Outputs = t.convertOutputs(terraformState.Values.Outputs) state.Resources = t.collectAzureResources(terraformState.Values.RootModule) return &provisioning.StateResult{ State: &state, }, nil } // Creates the terraform plan CLI arguments func (t *TerraformProvider) createPlanArgs(isRemoteBackendConfig bool) []string { args := []string{fmt.Sprintf("-var-file=%s", t.parametersFilePath())} if !isRemoteBackendConfig { args = append(args, fmt.Sprintf("-state=%s", t.localStateFilePath())) } return args } // Creates the terraform apply CLI arguments func (t *TerraformProvider) createApplyArgs( isRemoteBackendConfig bool, data terraformDeploymentDetails) ([]string, error) { args := []string{} if !isRemoteBackendConfig { args = append(args, fmt.Sprintf("-state=%s", data.localStateFilePath)) } if _, err := os.Stat(data.PlanFilePath); err == nil { args = append(args, data.PlanFilePath) } else { if _, err := os.Stat(data.ParameterFilePath); err != nil { return nil, fmt.Errorf("parameters file not found:: %w", err) } args = append(args, fmt.Sprintf("-var-file=%s", data.ParameterFilePath)) } return args, nil } // Creates the terraform destroy CLI arguments func (t *TerraformProvider) createDestroyArgs(isRemoteBackendConfig bool, autoApprove bool) []string { args := []string{fmt.Sprintf("-var-file=%s", t.parametersFilePath())} if !isRemoteBackendConfig { args = append(args, fmt.Sprintf("-state=%s", t.localStateFilePath())) } if autoApprove { args = append(args, "-auto-approve") } return args } // Checks if the parameters file already exists and creates if as needed. func (t *TerraformProvider) ensureParametersFile(ctx context.Context) error { if _, err := os.Stat(t.parametersFilePath()); err != nil { err := t.createInputParametersFile(ctx, t.parametersTemplateFilePath(), t.parametersFilePath()) if err != nil { return fmt.Errorf("creating parameters file: %w", err) } } return nil } // initialize template terraform provider through terraform init func (t *TerraformProvider) init(ctx context.Context, isRemoteBackendConfig bool) (string, error) { modulePath := t.modulePath() cmd := []string{} if isRemoteBackendConfig { t.console.Message(ctx, "Generating terraform backend config file...") err := t.createInputParametersFile(ctx, t.backendConfigTemplateFilePath(), t.backendConfigFilePath()) if err != nil { return fmt.Sprintf("creating terraform backend config file: %s", err), err } cmd = append(cmd, fmt.Sprintf("--backend-config=%s", t.backendConfigFilePath())) } runResult, err := t.cli.Init(ctx, modulePath, cmd...) if err != nil { return runResult, err } return runResult, nil } // Creates a normalized view of the terraform output. func (t *TerraformProvider) createOutputParameters( ctx context.Context, modulePath string, isRemoteBackend bool, ) (map[string]provisioning.OutputParameter, error) { cmd := []string{} if !isRemoteBackend { cmd = append(cmd, fmt.Sprintf("-state=%s", t.localStateFilePath())) } runResult, err := t.cli.Output(ctx, modulePath, cmd...) if err != nil { return nil, fmt.Errorf("reading deployment output failed: %s, err:%w", runResult, err) } var outputMap map[string]terraformOutput if err := json.Unmarshal([]byte(runResult), &outputMap); err != nil { return nil, err } return t.convertOutputs(outputMap), nil } func (t *TerraformProvider) mapTerraformTypeToInterfaceType(typ any) provisioning.ParameterType { // in the JSON output, the type property maps to either a string (for a primitive type) or an // array of things which describe a complex type. switch v := typ.(type) { case string: switch v { case "string": return provisioning.ParameterTypeString case "bool": return provisioning.ParameterTypeBoolean case "number": return provisioning.ParameterTypeNumber default: panic(fmt.Sprintf("unknown primitive type: %s", v)) } case []any: // in this case we have a complex type, which in json looked like ["type", <schema parts>...], just pull out the // first part and map to either and object or array. switch v[0].(string) { case "list", "tuple", "set": return provisioning.ParameterTypeArray case "object", "map": return provisioning.ParameterTypeObject default: panic(fmt.Sprintf("unknown complex type tag: %s (full type: %+v)", v, typ)) } } return provisioning.ParameterTypeString } // convertOutputs converts a terraform output map to the canonical format shared by all provider implementations. func (t *TerraformProvider) convertOutputs(outputMap map[string]terraformOutput) map[string]provisioning.OutputParameter { outputParameters := make(map[string]provisioning.OutputParameter) for k, v := range outputMap { if val, ok := v.Value.(string); ok && val == "null" { // omit null continue } outputParameters[k] = provisioning.OutputParameter{ Type: t.mapTerraformTypeToInterfaceType(v.Type), Value: v.Value, } } return outputParameters } func (t *TerraformProvider) showCurrentState( ctx context.Context, modulePath string, isRemoteBackend bool, ) (*terraformShowOutput, error) { cmd := []string{} if !isRemoteBackend { cmd = append(cmd, t.localStateFilePath()) } runResult, err := t.cli.Show(ctx, modulePath, cmd...) if err != nil { return nil, fmt.Errorf("showing current state failed: %s, err:%w", runResult, err) } var showOutput terraformShowOutput if err := json.Unmarshal([]byte(runResult), &showOutput); err != nil { return nil, err } return &showOutput, nil } // Creates the deployment object from the specified module path func (t *TerraformProvider) createDeployment(ctx context.Context) (*provisioning.Deployment, error) { templateParameters := make(map[string]provisioning.InputParameter) //build the template parameters. parameters := make(map[string]any) parametersFilePath := t.parametersFilePath() // check if the file does not exist to create it --> for shared env scenario log.Printf("Reading parameters template file from: %s", parametersFilePath) if err := t.ensureParametersFile(ctx); err != nil { return nil, err } parametersBytes, err := os.ReadFile(parametersFilePath) if err != nil { return nil, fmt.Errorf("reading parameter file template: %w", err) } if err := json.Unmarshal(parametersBytes, &parameters); err != nil { return nil, fmt.Errorf("error unmarshalling template parameters: %w", err) } for key, param := range parameters { templateParameters[key] = provisioning.InputParameter{ Type: key, Value: param, } } template := provisioning.Deployment{ Parameters: templateParameters, } return &template, nil } // collectAzureResources collects the set of resources from the root module of a terraform state file, including // resources from all child modules. Only resources managed by azure providers are considered (today, that's // just resources from the `registry.terraform.io/hashicorp/azurerm` provider). Only "managed" resources are // considered. func (t *TerraformProvider) collectAzureResources(rootModule terraformRootModule) []provisioning.Resource { // the set of resources we've seen (keyed by their id) azureResources := make(map[string]struct{}) // Walk over all the modules (starting at the root) and mark each resource we see from the azure // provider. visitResource := func(r terraformResource) { if r.Mode == terraformModeManaged && r.ProviderName == "registry.terraform.io/hashicorp/azurerm" { if id, err := t.getIdForManagedResource(r); err != nil { log.Printf("error determining id for resource: %v, ignoring...", err) } else { azureResources[id] = struct{}{} } } } var visitChildModule func(c terraformChildModule) visitChildModule = func(c terraformChildModule) { for _, r := range c.Resources { visitResource(r) } for _, c := range c.ChildModules { visitChildModule(c) } } for _, r := range rootModule.Resources { visitResource(r) } for _, c := range rootModule.ChildModules { visitChildModule(c) } // At this point, allResources contains the ids of all the resources we discovered. resources := make([]provisioning.Resource, 0, len(azureResources)) for id := range azureResources { resources = append(resources, provisioning.Resource{ Id: id, }) } return resources } func (t *TerraformProvider) getIdForManagedResource(r terraformResource) (string, error) { // Most azure resources use "id" as the key in the values bag that holds the resource id. However, some // resources are special and use a different key for the Azure Resource Id. idKey := "id" switch r.Type { case "azurerm_key_vault_secret": idKey = "resource_id" } if val, has := r.Values[idKey]; !has { return "", fmt.Errorf("resource %s has no %s property", r.Address, idKey) } else if id, ok := val.(string); !ok { return "", fmt.Errorf("resource %s has %s property with type %T not string", r.Address, idKey, val) } else { return id, nil } } // Gets the path to the project parameters file path func (t *TerraformProvider) parametersTemplateFilePath() string { infraPath := t.options.Path if strings.TrimSpace(infraPath) == "" { infraPath = "infra" } parametersFilename := fmt.Sprintf("%s.tfvars.json", t.options.Module) return filepath.Join(t.projectPath, infraPath, parametersFilename) } // Gets the path to the project backend config file path func (t *TerraformProvider) backendConfigTemplateFilePath() string { infraPath := t.options.Path if strings.TrimSpace(infraPath) == "" { infraPath = "infra" } return filepath.Join(t.projectPath, infraPath, "provider.conf.json") } // Gets the folder path to the specified module func (t *TerraformProvider) modulePath() string { infraPath := t.options.Path if strings.TrimSpace(infraPath) == "" { infraPath = "infra" } return filepath.Join(t.projectPath, infraPath) } // Gets the path to the staging .azure terraform plan file path func (t *TerraformProvider) planFilePath() string { planFilename := fmt.Sprintf("%s.tfplan", t.options.Module) return filepath.Join(t.projectPath, ".azure", t.env.Name(), t.options.Path, planFilename) } // Gets the path to the staging .azure terraform local state file path func (t *TerraformProvider) localStateFilePath() string { return filepath.Join(t.projectPath, ".azure", t.env.Name(), t.options.Path, "terraform.tfstate") } // Gets the path to the staging .azure parameters file path func (t *TerraformProvider) backendConfigFilePath() string { backendConfigFilename := fmt.Sprintf("%s.conf.json", t.env.Name()) return filepath.Join(t.projectPath, ".azure", t.env.Name(), t.options.Path, backendConfigFilename) } // Gets the path to the staging .azure backend config file path func (t *TerraformProvider) parametersFilePath() string { parametersFilename := fmt.Sprintf("%s.tfvars.json", t.options.Module) return filepath.Join(t.projectPath, ".azure", t.env.Name(), t.options.Path, parametersFilename) } // Gets the path to the current env. func (t *TerraformProvider) dataDirPath() string { return filepath.Join(t.projectPath, ".azure", t.env.Name(), t.options.Path, ".terraform") } // Check terraform file for remote backend provider func (t *TerraformProvider) isRemoteBackendConfig() (bool, error) { modulePath := t.modulePath() infraDir, _ := os.Open(modulePath) files, err := infraDir.ReadDir(0) if err != nil { return false, fmt.Errorf("reading .tf files contents: %w", err) } for index := range files { if !files[index].IsDir() && filepath.Ext(files[index].Name()) == ".tf" { fileContent, err := os.ReadFile(filepath.Join(modulePath, files[index].Name())) if err != nil { return false, fmt.Errorf("error reading .tf files: %w", err) } if found := strings.Contains(string(fileContent), `backend "azurerm"`); found { return true, nil } } } return false, nil } // Copies the an input parameters file templateFilePath to inputFilePath after replacing environment variable references in // the contents. func (t *TerraformProvider) createInputParametersFile( ctx context.Context, templateFilePath string, inputFilePath string, ) error { principalId, err := t.curPrincipal.CurrentPrincipalId(ctx) if err != nil { return fmt.Errorf("fetching current principal id: %w", err) } // Copy the parameter template file to the environment working directory and do substitutions. log.Printf("Reading parameters template file from: %s", templateFilePath) parametersBytes, err := os.ReadFile(templateFilePath) if err != nil { return fmt.Errorf("reading parameter file template: %w", err) } replaced, err := envsubst.Eval(string(parametersBytes), func(name string) string { if name == environment.PrincipalIdEnvVarName { return principalId } return t.env.Getenv(name) }) if err != nil { return fmt.Errorf("substituting parameter file: %w", err) } writeDir := filepath.Dir(inputFilePath) if err := os.MkdirAll(writeDir, osutil.PermissionDirectory); err != nil { return fmt.Errorf("creating directory structure: %w", err) } log.Printf("Writing parameters file to: %s", inputFilePath) err = os.WriteFile(inputFilePath, []byte(replaced), 0600) if err != nil { return fmt.Errorf("writing parameter file: %w", err) } return nil } // terraformShowOutput is a model type for the output of `terraform show` for a tfstate file. // see https://www.terraform.io/internals/json-format#state-representation for more information on the shape // of the JSON data type terraformShowOutput struct { FormatVersion string `json:"format_version"` Values terraformValues `json:"values"` } // terraformValues is a model type for the `values-representation` object in a JSON output from terraform. // see https://www.terraform.io/internals/json-format#values-representation for more information on the shape // of the JSON data. type terraformValues struct { Outputs map[string]terraformOutput `json:"outputs"` RootModule terraformRootModule `json:"root_module"` } // terraformOutput is a model type for the value in the output map. type terraformOutput struct { Value any `json:"value"` // This is either a string or an array objects for a complex type Type any `json:"type"` Sensitive bool `json:"sensitive"` } // terraformRootModule is a model type for the "root_module" property the JSON output of a state file. type terraformRootModule struct { Resources []terraformResource `json:"resources"` ChildModules []terraformChildModule `json:"child_modules"` } const terraformModeManaged = "managed" // terraformResource is the model type for a resource in a terraform state file. The "values" // array contains provider specific values (for azurerm, this includes "id" which is the resource id). type terraformResource struct { Address string `json:"address"` ProviderName string `json:"provider_name"` // "mode" can be "managed", for resources, or "data", for data resources Mode string `json:"mode"` Type string `json:"type"` Values map[string]any `json:"values"` } // terraformChildModule is the model type for a child module in the state file. It may contain // further child modules which contain additional resources. type terraformChildModule struct { Address string `json:"address"` Resources []terraformResource `json:"resources"` ChildModules []terraformChildModule `json:"child_modules"` }