pkg/modulewriter/tfwriter.go (323 lines of code) (raw):
/**
* Copyright 2022 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 modulewriter
import (
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"github.com/hashicorp/hcl/v2/ext/typeexpr"
"github.com/hashicorp/hcl/v2/hclwrite"
"github.com/zclconf/go-cty/cty"
"golang.org/x/exp/maps"
"golang.org/x/exp/slices"
"hpc-toolkit/pkg/config"
"hpc-toolkit/pkg/modulereader"
)
const (
tfStateFileName = "terraform.tfstate"
tfStateBackupFileName = "terraform.tfstate.backup"
)
// TFWriter writes terraform to the blueprint folder
type TFWriter struct{}
func writeHclFile(path string, hclFile *hclwrite.File) error {
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("error writing %q: %v", path, err)
}
defer f.Close()
if _, err := f.WriteString(license); err != nil {
return fmt.Errorf("error writing %q: %v", path, err)
}
if _, err := f.Write(hclwrite.Format(hclFile.Bytes())); err != nil {
return fmt.Errorf("error writing %q: %v", path, err)
}
return nil
}
func writeOutputs(
modules []config.Module,
dst string,
) error {
// Create hcl body
hclFile := hclwrite.NewEmptyFile()
hclBody := hclFile.Body()
outputs := []string{}
// Add all outputs from each module
for _, mod := range modules {
for _, output := range mod.Outputs {
outputName := config.AutomaticOutputName(output.Name, mod.ID)
outputs = append(outputs, outputName)
hclBody.AppendNewline()
hclBlock := hclBody.AppendNewBlock("output", []string{outputName})
blockBody := hclBlock.Body()
desc := output.Description
if desc == "" {
desc = fmt.Sprintf("Generated output from module '%s'", mod.ID)
}
blockBody.SetAttributeValue("description", cty.StringVal(desc))
ref := config.ModuleRef(mod.ID, output.Name).AsValue()
blockBody.SetAttributeRaw("value", config.TokensForValue(ref))
if output.Sensitive {
blockBody.SetAttributeValue("sensitive", cty.BoolVal(output.Sensitive))
}
}
}
if len(outputs) == 0 {
return nil
}
return writeHclFile(filepath.Join(dst, "outputs.tf"), hclFile)
}
func writeTfvars(vars map[string]cty.Value, dst string) error {
return WriteHclAttributes(vars, filepath.Join(dst, "terraform.tfvars"))
}
func relaxVarType(t cty.Type) cty.Type {
if t.IsPrimitiveType() {
return t
}
if t.IsListType() || t.IsTupleType() || t.IsSetType() {
return cty.List(cty.DynamicPseudoType) // list of any
}
return cty.DynamicPseudoType // any
}
func getTypeTokens(ty cty.Type) hclwrite.Tokens {
// TODO: don't use TokensForIdentifier
// This is a temporary solution until we have a better way to tokenize types
return hclwrite.TokensForIdentifier(typeexpr.TypeString(ty))
}
func writeVariables(vars map[string]cty.Value, extraVars []modulereader.VarInfo, dst string) error {
var inputs []modulereader.VarInfo
for k, v := range vars {
inputs = append(inputs, modulereader.VarInfo{
Name: k,
Type: relaxVarType(v.Type()),
Description: fmt.Sprintf("Toolkit deployment variable: %s", k),
})
}
inputs = append(inputs, extraVars...)
slices.SortFunc(inputs, func(i, j modulereader.VarInfo) int { return strings.Compare(i.Name, j.Name) })
// Create HCL Body
hclFile := hclwrite.NewEmptyFile()
hclBody := hclFile.Body()
// create variable block for each input
for _, k := range inputs {
hclBody.AppendNewline()
hclBlock := hclBody.AppendNewBlock("variable", []string{k.Name})
blockBody := hclBlock.Body()
blockBody.SetAttributeValue("description", cty.StringVal(k.Description))
blockBody.SetAttributeRaw("type", getTypeTokens(k.Type))
}
return writeHclFile(filepath.Join(dst, "variables.tf"), hclFile)
}
func writeMain(
modules []config.Module,
tfBackend config.TerraformBackend,
dst string,
) error {
hclFile := hclwrite.NewEmptyFile()
hclBody := hclFile.Body()
// Write Terraform backend if needed
if tfBackend.Type != "" {
hclBody.AppendNewline()
tfBody := hclBody.AppendNewBlock("terraform", []string{}).Body()
backendBlock := tfBody.AppendNewBlock("backend", []string{tfBackend.Type})
backendBody := backendBlock.Body()
vals := tfBackend.Configuration.Items()
for _, setting := range orderKeys(vals) {
backendBody.SetAttributeValue(setting, vals[setting])
}
}
for _, mod := range modules {
hclBody.AppendNewline()
// Add block
moduleBlock := hclBody.AppendNewBlock("module", []string{string(mod.ID)})
moduleBody := moduleBlock.Body()
// Add source attribute
ds, err := DeploymentSource(mod)
if err != nil {
return err
}
moduleBody.SetAttributeValue("source", cty.StringVal(ds))
// For each Setting
for _, setting := range orderKeys(mod.Settings.Items()) {
value := mod.Settings.Get(setting)
moduleBody.SetAttributeRaw(setting, config.TokensForValue(value))
}
}
return writeHclFile(filepath.Join(dst, "main.tf"), hclFile)
}
func writeProviders(providers map[string]config.TerraformProvider, dst string) error {
hclFile := hclwrite.NewEmptyFile()
hclBody := hclFile.Body()
for _, k := range orderKeys(providers) {
hclBody.AppendNewline()
v := providers[k]
pb := hclBody.AppendNewBlock("provider", []string{k}).Body()
for _, s := range orderKeys(v.Configuration.Items()) {
pb.SetAttributeRaw(s, config.TokensForValue(v.Configuration.Get(s)))
}
}
return writeHclFile(filepath.Join(dst, "providers.tf"), hclFile)
}
func writeVersions(providers map[string]config.TerraformProvider, dst string) error {
f := hclwrite.NewEmptyFile()
body := f.Body()
body.AppendNewline()
tfb := body.AppendNewBlock("terraform", []string{}).Body()
tfb.SetAttributeValue("required_version", cty.StringVal(">= 1.2"))
tfb.AppendNewline()
pb := tfb.AppendNewBlock("required_providers", []string{}).Body()
for _, k := range orderKeys(providers) {
v := providers[k]
pb.SetAttributeValue(k, cty.ObjectVal(map[string]cty.Value{
"source": cty.StringVal(v.Source),
"version": cty.StringVal(v.Version),
}))
}
return writeHclFile(filepath.Join(dst, "versions.tf"), f)
}
func writeTerraformInstructions(w io.Writer, grpPath string, n config.GroupName, printExportOutputs bool, printImportInputs bool) {
fmt.Fprintln(w)
fmt.Fprintf(w, "Terraform group '%s' was successfully created in directory %s\n", n, grpPath)
fmt.Fprintln(w, "To deploy, run the following commands:")
fmt.Fprintln(w)
if printImportInputs {
fmt.Fprintf(w, "gcluster import-inputs %s\n", grpPath)
}
fmt.Fprintf(w, "terraform -chdir=%s init\n", grpPath)
fmt.Fprintf(w, "terraform -chdir=%s validate\n", grpPath)
fmt.Fprintf(w, "terraform -chdir=%s apply\n", grpPath)
if printExportOutputs {
fmt.Fprintf(w, "gcluster export-outputs %s\n", grpPath)
}
}
// writeGroup creates and sets up the terraform deployment group
func (w TFWriter) writeGroup(
bp config.Blueprint,
groupIndex int,
groupPath string,
instructions io.Writer,
) error {
g := bp.Groups[groupIndex]
deploymentVars := getUsedDeploymentVars(g, bp)
intergroupVars := FindIntergroupVariables(g, bp)
intergroupInputs := make(map[string]bool)
for _, igVar := range intergroupVars {
intergroupInputs[igVar.Name] = true
}
tp := g.TerraformProviders
// Write main.tf file
doctoredModules, err := substituteIgcReferences(g.Modules, intergroupVars)
if err != nil {
return fmt.Errorf("error substituting intergroup references in deployment group %s: %w", g.Name, err)
}
if err := writeMain(doctoredModules, g.TerraformBackend, groupPath); err != nil {
return fmt.Errorf("error writing main.tf file for deployment group %s: %w", g.Name, err)
}
// Write variables.tf file
if err := writeVariables(deploymentVars, maps.Values(intergroupVars), groupPath); err != nil {
return fmt.Errorf("error writing variables.tf file for deployment group %s: %w", g.Name, err)
}
// Write outputs.tf file
if err := writeOutputs(g.Modules, groupPath); err != nil {
return fmt.Errorf("error writing outputs.tf file for deployment group %s: %w", g.Name, err)
}
// Write terraform.tfvars file
if err := writeTfvars(deploymentVars, groupPath); err != nil {
return fmt.Errorf("error writing terraform.tfvars file for deployment group %s: %w", g.Name, err)
}
// Write providers.tf file
if err := writeProviders(tp, groupPath); err != nil {
return fmt.Errorf("error writing providers.tf file for deployment group %s: %w", g.Name, err)
}
// Write versions.tf file
if err := writeVersions(tp, groupPath); err != nil {
return fmt.Errorf("error writing versions.tf file for deployment group %s: %v", g.Name, err)
}
multiGroupDeployment := len(bp.Groups) > 1
printImportInputs := multiGroupDeployment && groupIndex > 0
printExportOutputs := multiGroupDeployment && groupIndex < len(bp.Groups)-1
writeTerraformInstructions(instructions, groupPath, g.Name, printExportOutputs, printImportInputs)
return nil
}
// Transfers state files from previous resource groups (in .ghpc/) to a newly written blueprint
func (w TFWriter) restoreState(deploymentDir string) error {
prevGroupPath := filepath.Join(HiddenGhpcDir(deploymentDir), prevGroupDirName)
files, err := os.ReadDir(prevGroupPath)
if err != nil {
return fmt.Errorf("error trying to read previous modules in %s, %w", prevGroupPath, err)
}
for _, f := range files {
var tfStateFiles = []string{tfStateFileName, tfStateBackupFileName}
for _, stateFile := range tfStateFiles {
src := filepath.Join(prevGroupPath, f.Name(), stateFile)
dest := filepath.Join(deploymentDir, f.Name(), stateFile)
if bytesRead, err := os.ReadFile(src); err == nil {
err = os.WriteFile(dest, bytesRead, 0644)
if err != nil {
return fmt.Errorf("failed to write previous state file %s, %w", dest, err)
}
}
}
}
return nil
}
func orderKeys[T any](settings map[string]T) []string {
keys := make([]string, 0, len(settings))
for k := range settings {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
func getUsedDeploymentVars(group config.Group, bp config.Blueprint) map[string]cty.Value {
res := map[string]cty.Value{
// labels must always be written as a variable as it is implicitly added
"labels": bp.Vars.Get("labels"),
}
used := []string{}
for _, m := range group.Modules {
used = append(used, config.GetUsedDeploymentVars(m.Settings.AsObject())...)
}
for _, v := range group.TerraformProviders {
used = append(used, config.GetUsedDeploymentVars(v.Configuration.AsObject())...)
}
for _, v := range used {
res[v] = bp.Vars.Get(v)
}
return res
}
func substituteIgcReferences(mods []config.Module, igcRefs map[config.Reference]modulereader.VarInfo) ([]config.Module, error) {
doctoredMods := make([]config.Module, len(mods))
for i, mod := range mods {
dm, err := SubstituteIgcReferencesInModule(mod, igcRefs)
if err != nil {
return nil, err
}
doctoredMods[i] = dm
}
return doctoredMods, nil
}
// SubstituteIgcReferencesInModule updates expressions in Module settings to use
// special IGC var name instead of the module reference
func SubstituteIgcReferencesInModule(mod config.Module, igcRefs map[config.Reference]modulereader.VarInfo) (config.Module, error) {
v, err := cty.Transform(mod.Settings.AsObject(), func(p cty.Path, v cty.Value) (cty.Value, error) {
e, is := config.IsExpressionValue(v)
if !is {
return v, nil
}
refs := e.References()
for _, r := range refs {
oi, exists := igcRefs[r]
if !exists {
continue
}
old := r.AsExpression()
new := config.GlobalRef(oi.Name).AsExpression()
var err error
if e, err = config.ReplaceSubExpressions(e, old, new); err != nil {
return cty.NilVal, err
}
}
return e.AsValue(), nil
})
if err != nil {
return config.Module{}, err
}
mod.Settings = config.NewDict(v.AsValueMap())
return mod, nil
}
// FindIntergroupVariables returns all unique intergroup references made by
// each module settings in a group
func FindIntergroupVariables(group config.Group, bp config.Blueprint) map[config.Reference]modulereader.VarInfo {
res := map[config.Reference]modulereader.VarInfo{}
igcRefs := group.FindAllIntergroupReferences(bp)
for _, r := range igcRefs {
n := config.AutomaticOutputName(r.Name, r.Module)
res[r] = modulereader.VarInfo{
Name: n,
Type: cty.DynamicPseudoType,
Description: "Automatically generated input from previous groups (gcluster import-inputs --help)",
Required: true,
}
}
return res
}
func (w TFWriter) kind() config.ModuleKind {
return config.TerraformKind
}