pkg/shell/terraform.go (375 lines of code) (raw):

/** * Copyright 2023 Google LLC * * Licensed 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 shell import ( "bufio" "context" "encoding/json" "fmt" "hpc-toolkit/pkg/config" "hpc-toolkit/pkg/logging" "hpc-toolkit/pkg/modulereader" "hpc-toolkit/pkg/modulewriter" "os" "os/exec" "path/filepath" "regexp" "strings" "github.com/hashicorp/terraform-exec/tfexec" "github.com/zclconf/go-cty/cty" "github.com/zclconf/go-cty/cty/gocty" ) // OutputFormat determines the format in which the errors are reported. // Current supported format are text (default option) and JSON. type OutputFormat uint // Future option could be ProtoBuf const ( TextOutput OutputFormat = iota JsonOutput ) // ApplyBehavior abstracts behaviors for making changes to cloud infrastructure // when gcluster believes that they may be necessary type ApplyBehavior uint // 3 behaviors making changes: never, automatic, and explicit approval const ( NeverApply ApplyBehavior = iota AutomaticApply PromptBeforeApply ) type outputValue struct { Name string Sensitive bool Type cty.Type Value cty.Value } func tfExecPath() (string, error) { path, err := exec.LookPath("terraform") if err != nil { return "", config.HintError{ Hint: "must have a copy of terraform installed in PATH (obtain at https://terraform.io)", Err: err} } return path, nil } // ConfigureTerraform returns a Terraform object used to execute commands func ConfigureTerraform(workingDir string) (*tfexec.Terraform, error) { path, err := tfExecPath() if err != nil { return nil, err } return tfexec.NewTerraform(workingDir, path) } // this function executes a lightweight "terraform init" that is designed to // test if the root module was previously initialized and is consistent with // the current code; it will not download modules or configure backends, but it // will download plugins (e.g. google provider) as needed; no reliable mechanism // has been found (e.g. tfexec.PluginDir("/dev/null")) that avoids erroring on // properly-initialized root modules func needsInit(tf *tfexec.Terraform) bool { getOpt := tfexec.Get(false) backendOpt := tfexec.Backend(false) e := tf.Init(context.Background(), getOpt, backendOpt) return e != nil } func initModule(tf *tfexec.Terraform) error { var err error if needsInit(tf) { logging.Info("Initializing deployment group %s", tf.WorkingDir()) err = tf.Init(context.Background()) } if err != nil { return config.HintError{ Hint: fmt.Sprintf("initialization of deployment group %s failed; manually resolve errors", tf.WorkingDir()), Err: err} } return err } func outputModule(tf *tfexec.Terraform) (map[string]cty.Value, error) { logging.Info("Collecting terraform outputs from %s", tf.WorkingDir()) output, err := tf.Output(context.Background()) if err != nil { return map[string]cty.Value{}, config.HintError{ Hint: fmt.Sprintf("collecting terraform outputs from deployment group %s failed; manually resolve errors", tf.WorkingDir()), Err: err} } outputValues := make(map[string]cty.Value, len(output)) for k, v := range output { ov := outputValue{Name: k, Sensitive: v.Sensitive} if err := json.Unmarshal(v.Type, &ov.Type); err != nil { return map[string]cty.Value{}, err } var s interface{} if err := json.Unmarshal(v.Value, &s); err != nil { return map[string]cty.Value{}, err } if ov.Value, err = gocty.ToCtyValue(s, ov.Type); err != nil { return map[string]cty.Value{}, err } outputValues[ov.Name] = ov.Value } return outputValues, nil } // See https://github.com/hashicorp/terraform/blob/4ce385a19b93cf7f1b7780d9b2d3cadc5d0ddb31/internal/command/views/json/diagnostic.go#L34 type Diagnostic struct { Severity string `json:"severity"` Summary string `json:"summary"` Detail string `json:"detail"` } type JsonMessage struct { Level string `json:"@level"` Diagnostic Diagnostic `json:"diagnostic"` } func parseJsonMessages(data string) []JsonMessage { res := []JsonMessage{} // Every message is on a single line sc := bufio.NewScanner(strings.NewReader(data)) for sc.Scan() { var msg JsonMessage if err := json.Unmarshal([]byte(sc.Text()), &msg); err != nil { continue // silently skip the message } res = append(res, msg) } return res } func helpOnPlanError(msgs []JsonMessage) string { missingVar := false for _, msg := range msgs { missingVar = missingVar || msg.Diagnostic.Summary == "No value for required variable" } if missingVar { // Based on assumption that the only undefined variables can possibly come from IGC references. // This may change in the future. return `run "gcluster export-outputs" on previous deployment groups to define inputs` } else { return "" } } func planModule(tf *tfexec.Terraform, path string, destroy bool) (bool, error) { outOpt := tfexec.Out(path) var jsonOut strings.Builder wantsChange, err := tf.PlanJSON(context.Background(), &jsonOut, outOpt, tfexec.Destroy(destroy)) if err != nil { // Invoke `Plan` to get human-readable error. // TODO: implement rendering to avoid double-call. // Note planned deprecration of Plan in favor of JSON-only format // https://github.com/hashicorp/terraform-exec/blob/1b7714111a94813e92936051fb3014fec81218d5/tfexec/plan.go#L128-L129 _, plainError := tf.Plan(context.Background(), tfexec.Destroy(destroy)) if plainError == nil { // shouldn't happen plainError = err // fallback to original error (simple `exit status 1`) } msg := fmt.Sprintf("terraform plan for deployment group %s failed", tf.WorkingDir()) help := helpOnPlanError(parseJsonMessages(jsonOut.String())) if len(help) > 0 { msg = fmt.Sprintf("%s; %s", msg, help) } return false, config.HintError{Hint: msg, Err: plainError} } return wantsChange, nil } func promptForApply(tf *tfexec.Terraform, path string, b ApplyBehavior) bool { switch b { case AutomaticApply: return true case PromptBeforeApply: plan, err := tf.ShowPlanFileRaw(context.Background(), path) if err != nil { return false } re := regexp.MustCompile(`Plan: .*\n`) summary := re.FindString(plan) if summary == "" { summary = fmt.Sprintf("Please review full proposed changes for deployment group %s", tf.WorkingDir()) } changes := ProposedChanges{ Summary: summary, Full: plan, } return ApplyChangesChoice(changes) default: return false } } // This function applies the terraform plan, but generates outputs in JSON format // (instead of text) func applyPlanJsonOutput(tf *tfexec.Terraform, path string) error { planFileOpt := tfexec.DirOrPlan(path) logging.Info("Running terraform apply on deployment group %s", tf.WorkingDir()) // To do: Make file name as a user input // Make the JSON file name unique by having the Terraform dir as a substring jsonFilename := "gcluster_output-" + strings.ReplaceAll(tf.WorkingDir(), string(os.PathSeparator), ".") + ".json" logging.Info("Writing output to JSON file %s", jsonFilename) if jsonFile, err := os.Create(jsonFilename); err != nil { logging.Info("Cannot create JSON output file %s", jsonFilename) return err } else { defer jsonFile.Close() tf.SetStdout(os.Stdout) tf.SetStderr(os.Stderr) if err := tf.ApplyJSON(context.Background(), jsonFile, planFileOpt); err != nil { return err } tf.SetStdout(nil) tf.SetStderr(nil) return nil } } func applyPlanConsoleOutput(tf *tfexec.Terraform, path string) error { planFileOpt := tfexec.DirOrPlan(path) logging.Info("Running terraform apply on deployment group %s", tf.WorkingDir()) tf.SetStdout(os.Stdout) tf.SetStderr(os.Stderr) if err := tf.Apply(context.Background(), planFileOpt); err != nil { return err } tf.SetStdout(nil) tf.SetStderr(nil) return nil } // generate a Terraform plan to apply or destroy a module // recall "destroy" is just an alias for "apply -destroy"! // apply the plan automatically or after prompting the user func applyOrDestroy(tf *tfexec.Terraform, b ApplyBehavior, of OutputFormat, destroy bool) error { action := "adding or changing" pastTense := "applied" if destroy { action = "destroying" pastTense = "destroyed" } if err := initModule(tf); err != nil { return err } logging.Info("Testing if deployment group %s requires %s cloud infrastructure", tf.WorkingDir(), action) // capture Terraform plan in a file f, err := os.CreateTemp("", "plan-)") if err != nil { return err } defer os.Remove(f.Name()) wantsChange, err := planModule(tf, f.Name(), destroy) if err != nil { return err } var apply bool if wantsChange { logging.Info("Deployment group %s requires %s cloud infrastructure", tf.WorkingDir(), action) apply = b == AutomaticApply || promptForApply(tf, f.Name(), b) } else { logging.Info("Cloud infrastructure in deployment group %s is already %s", tf.WorkingDir(), pastTense) } if !apply { return nil } switch of { case JsonOutput: if err := applyPlanJsonOutput(tf, f.Name()); err != nil { return err } case TextOutput: // Text output to the console is also the default choice if err := applyPlanConsoleOutput(tf, f.Name()); err != nil { return err } default: panic("Unknown output format requested") } return nil } func getOutputs(tf *tfexec.Terraform, b ApplyBehavior, o OutputFormat) (map[string]cty.Value, error) { err := applyOrDestroy(tf, b, o, false) if err != nil { return nil, err } outputValues, err := outputModule(tf) if err != nil { return nil, err } return outputValues, nil } func outputsFile(artifactsDir string, group config.GroupName) string { return filepath.Join(artifactsDir, fmt.Sprintf("%s_outputs.tfvars", string(group))) } // ExportOutputs will run terraform output and capture data needed for // subsequent deployment groups func ExportOutputs(tf *tfexec.Terraform, artifactsDir string, applyBehavior ApplyBehavior, o OutputFormat) error { thisGroup := config.GroupName(filepath.Base(tf.WorkingDir())) filepath := outputsFile(artifactsDir, thisGroup) outputValues, err := getOutputs(tf, applyBehavior, o) if err != nil { return err } // TODO: confirm that outputValues has keys we would expect from the // blueprint; edge case is that "terraform output" can be missing keys // whose values are null if len(outputValues) == 0 { logging.Info("Deployment group %s contains no artifacts to export", thisGroup) return nil } logging.Info("Writing outputs artifact from deployment group %s to file %s", thisGroup, filepath) if err := modulewriter.WriteHclAttributes(outputValues, filepath); err != nil { return err } return nil } // for each prior group, read all output values and filter for those needed as input values to this group func gatherUpstreamOutputs(deploymentRoot string, artifactsDir string, g config.Group, bp config.Blueprint) (map[string]cty.Value, error) { outputsByGroup, err := config.OutputNamesByGroup(g, bp) if err != nil { return nil, err } res := map[string]cty.Value{} for pg, outputs := range outputsByGroup { if len(outputs) == 0 { continue } logging.Info("collecting outputs for group %q from group %q", g.Name, pg) filepath := outputsFile(artifactsDir, pg) gVals, err := modulereader.ReadHclAttributes(filepath) if err != nil { return nil, config.HintError{ Hint: fmt.Sprintf("consider running \"gcluster export-outputs %s/%s\"", deploymentRoot, pg), Err: err} } vals := intersectMapKeys(outputs, gVals) // filter for needed outputs if err := mergeMapsWithoutLoss(res, vals); err != nil { return nil, err } } return res, nil } // ImportInputs will search artifactsDir for files produced by ExportOutputs and // combine/filter them for the input values needed by the group in the Terraform // working directory func ImportInputs(groupDir string, artifactsDir string, bp config.Blueprint) error { deploymentRoot := filepath.Clean(filepath.Join(groupDir, "..")) g, err := bp.Group(config.GroupName(filepath.Base(groupDir))) if err != nil { return err } inputs, err := gatherUpstreamOutputs(deploymentRoot, artifactsDir, g, bp) if err != nil { return err } if len(inputs) == 0 { return nil } var outFile string var toImport map[string]cty.Value // input values to be imported switch g.Kind() { case config.TerraformKind: outFile = fmt.Sprintf("%s_inputs.auto.tfvars", g.Name) toImport = inputs // import all case config.PackerKind: // Packer groups are enforced to have length 1 mod := g.Modules[0] modPath, err := modulewriter.DeploymentSource(mod) if err != nil { return err } // evaluate Packer settings that contain intergroup references in the // context of deployment variables and intergroup output values intergroupSettings := map[string]cty.Value{} for setting, value := range mod.Settings.Items() { igcRefs := config.FindIntergroupReferences(value, mod, bp) if len(igcRefs) > 0 { intergroupSettings[setting] = value } } igcVars := modulewriter.FindIntergroupVariables(g, bp) newModule, err := modulewriter.SubstituteIgcReferencesInModule(config.Module{Settings: config.NewDict(intergroupSettings)}, igcVars) if err != nil { return err } if err := mergeMapsWithoutLoss(inputs, bp.Vars.Items()); err != nil { return err } fakeBP := config.Blueprint{Vars: config.NewDict(inputs)} evaluatedSettings, err := fakeBP.EvalDict(newModule.Settings) if err != nil { return err } outFile = filepath.Join(modPath, fmt.Sprintf("%s_inputs.auto.pkrvars.hcl", mod.ID)) toImport = evaluatedSettings.Items() default: return fmt.Errorf("unknown module kind for deployment group %s", g.Name) } outPath := filepath.Join(groupDir, outFile) logging.Info("Writing outputs for deployment group %s to file %s", g.Name, outPath) return modulewriter.WriteHclAttributes(toImport, outPath) } // Destroy destroys all infrastructure in the module working directory func Destroy(tf *tfexec.Terraform, b ApplyBehavior, o OutputFormat) error { return applyOrDestroy(tf, b, o, true) } func TfVersion() (string, error) { path, err := tfExecPath() if err != nil { return "", err } out, err := exec.Command(path, "version", "--json").Output() if err != nil { return "", err } var version struct { TerraformVersion string `json:"terraform_version"` } if err := json.Unmarshal(out, &version); err != nil { return "", err } return version.TerraformVersion, nil }