internal/tfengine/tfengine.go (267 lines of code) (raw):

// Copyright 2021 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 tfengine implements the Terraform Engine. package tfengine import ( "errors" "fmt" "io/ioutil" "log" "os" "path/filepath" "reflect" "regexp" "strings" "github.com/GoogleCloudPlatform/healthcare-data-protection-suite/cmd" "github.com/GoogleCloudPlatform/healthcare-data-protection-suite/internal/fileutil" "github.com/GoogleCloudPlatform/healthcare-data-protection-suite/internal/hcl" "github.com/GoogleCloudPlatform/healthcare-data-protection-suite/internal/jsonschema" "github.com/GoogleCloudPlatform/healthcare-data-protection-suite/internal/licenseutil" "github.com/GoogleCloudPlatform/healthcare-data-protection-suite/internal/runner" "github.com/GoogleCloudPlatform/healthcare-data-protection-suite/internal/template" "github.com/GoogleCloudPlatform/healthcare-data-protection-suite/internal/version" "github.com/otiai10/copy" ) // Options is the options for tfengine execution. type Options struct { Format bool AddLicenses bool CacheDir string ShowUnmanaged bool DeleteUnmanaged bool // Leave empty to generate all templates. WantedTemplates map[string]bool } // Run executes the main tfengine logic. func Run(confPath, outPath string, opts *Options) error { var err error confPath, err = fileutil.Expand(confPath) if err != nil { return err } outPath, err = fileutil.Expand(outPath) if err != nil { return err } c, err := loadConfig(confPath, nil) if err != nil { return err } compat, err := version.IsCompatible(c.Version) if err != nil { return err } if !compat { return fmt.Errorf("Binary version %v incompatible with template version constraint %v in %v", cmd.Version, c.Version, confPath) } tmpDir, err := ioutil.TempDir("", "") if err != nil { return err } defer os.RemoveAll(tmpDir) if err := dump(c, filepath.Dir(confPath), opts.CacheDir, tmpDir, opts.WantedTemplates); err != nil { return err } if err := os.MkdirAll(outPath, 0755); err != nil { return fmt.Errorf("mkdir %q: %v", outPath, err) } var errs []string if opts.AddLicenses { if err := licenseutil.AddLicense(tmpDir); err != nil { errs = append(errs, fmt.Sprintf("add license header: %v", err)) } } if opts.Format { if err := hcl.FormatDir(&runner.Default{Quiet: true}, tmpDir); err != nil { errs = append(errs, fmt.Sprintf("format output dir: %v", err)) } } // Show unmanaged files and optionally control deletion via flag. if opts.ShowUnmanaged { if err := findUnmanaged(tmpDir, outPath, opts.DeleteUnmanaged); err != nil { errs = append(errs, err.Error()) } } if err := copy.Copy(tmpDir, outPath); err != nil { errs = append(errs, fmt.Sprintf("copy temp dir to output dir: %v", err)) } if len(errs) > 0 { return errors.New(strings.Join(errs, "\n")) } return nil } func dump(conf *Config, pwd, cacheDir, outputPath string, wantedTemplates map[string]bool) error { for _, ti := range conf.Templates { // If a templates filter was provided, check against it. if len(wantedTemplates) > 0 { if _, ok := wantedTemplates[ti.Name]; !ok { continue } // Mark it so we can report on wrong template values at the end. // Don't delete, to avoid modifying the underlying data structure. wantedTemplates[ti.Name] = false } if err := dumpTemplate(conf, pwd, cacheDir, outputPath, ti); err != nil { return fmt.Errorf("template %q: %v", ti.Name, err) } } // Report on any templates that were supposed to be generated but weren't. // This most likely indicates a misspelling. var missingTemplates []string for t, missed := range wantedTemplates { if missed { missingTemplates = append(missingTemplates, t) } // Unmark. wantedTemplates[t] = true } if len(missingTemplates) > 0 { return fmt.Errorf("templates not found in engine config: %v", strings.Join(missingTemplates, ",")) } return nil } func dumpTemplate(conf *Config, pwd, cacheDir, outputPath string, ti *templateInfo) error { outputPath = filepath.Join(outputPath, ti.OutputPath) data, err := template.CopyData(conf.Data) if err != nil { return err } // Make the schema available. trimDescriptions(conf.Schema) data["__schema__"] = conf.Schema flattenedData, err := template.FlattenData(data, ti.Flatten) if err != nil { return err } // Merge flattened data into template data so that it gets checked by the schema check later. if err := template.MergeData(ti.Data, flattenedData); err != nil { return err } if err := template.MergeData(data, ti.Data); err != nil { return err } // Pass through keys that should be validated against the schema. // Don't do error checking if keys are missing, instead let schemas // in the child templates do the validation. for _, k := range ti.Passthrough { if v, ok := conf.Data[k]; ok { ti.Data[k] = v } } switch { case ti.RecipePath != "": rp, err := fileutil.Fetch(ti.RecipePath, pwd, cacheDir) if err != nil { return err } rc, err := loadConfig(rp, data) if err != nil { return fmt.Errorf("load recipe %q: %v", rp, err) } compat, err := version.IsCompatible(rc.Version) if err != nil { return err } if !compat { return fmt.Errorf("binary version %v incompatible with template version constraint %v in %v", cmd.Version, rc.Version, rp) } // Validate the schema, if present. if len(rc.Schema) > 0 { // Only check against unmerged template data so we can disallow additional properties in the schema. if err := jsonschema.ValidateMap(rc.Schema, ti.Data); err != nil { return fmt.Errorf("recipe %q: %v", rp, err) } } // Each recipe could have a top-level data block. Keep it and merge, instead of overrwriting. if err := template.MergeData(rc.Data, data); err != nil { return err } if err := dump(rc, filepath.Dir(rp), cacheDir, outputPath, nil); err != nil { return fmt.Errorf("recipe %q: %v", rp, err) } case ti.ComponentPath != "": // Fetch the component, which could be remote. cp, err := fileutil.Fetch(ti.ComponentPath, pwd, cacheDir) if err != nil { return err } write := template.WriteDir // If the component path is a single file, // treat the output_path as a file name and only write that out. // Otherwise, treat as directory path info, err := os.Stat(cp) if err != nil { return fmt.Errorf("stat %q: %v", cp, err) } if info.Mode().IsRegular() { write = template.WriteFile } if err := write(cp, outputPath, data); err != nil { return fmt.Errorf("component %q: %v", cp, err) } } return nil } // backendRE is a regex to capture GCS backend blocks in configs. // The 's' and 'U' flags allow capturing multi line backend blocks in a lazy manner. var backendRE = regexp.MustCompile(`(?sU)backend "gcs" {.*}`) // remoteStateResourceRE is a regex to capture terraform_remote_state data blocks in configs. // The 's' and 'U' flags allow capturing multi line backend blocks in a lazy manner. var remoteStateResourceRE = regexp.MustCompile(`(?sU)data "terraform_remote_state" .* {.*}\n}`) // remoteStateDataRE is a regex to capture usages of terraform_remote_state in configs. var remoteStateDataRE = regexp.MustCompile(`("\${data\.terraform_remote_state\..*}")|(data\.terraform_remote_state\.\S*)`) // ConvertToLocalBackend converts all the Terraform backend blocks to "local". // path should be a path to the root of the configs generated by the Engine. func ConvertToLocalBackend(path string) error { // Replace all GCS backend blocks with local. fn := func(path string, info os.FileInfo, err error) error { if err != nil { return fmt.Errorf("walk path %q: %v", path, err) } if filepath.Ext(path) != ".tf" { return nil } b, err := ioutil.ReadFile(path) if err != nil { return fmt.Errorf("read file %q: %v", path, err) } b = backendRE.ReplaceAll(b, nil) b = remoteStateResourceRE.ReplaceAll(b, nil) // Replace data.terraform_remote_state references with a dummy string value. b = remoteStateDataRE.ReplaceAll(b, []byte(`"000"`)) if err := ioutil.WriteFile(path, b, 0644); err != nil { return fmt.Errorf("write file %q: %v", path, err) } return nil } if err := filepath.Walk(path, fn); err != nil { return fmt.Errorf("walk %qs: %v", path, err) } return nil } // findUnmanaged walks both directories and shows all files in the output // directory which aren't in the generated files directory. // If the deleteFiles flag is set, it will also delete these files. func findUnmanaged(generatedDir, outputDir string, deleteFiles bool) error { managed := make(map[string]bool) mkFn := func(addToManaged bool, root string) func(path string, info os.FileInfo, err error) error { root = filepath.Clean(root) return func(path string, info os.FileInfo, err error) error { if err != nil { return fmt.Errorf("walk path %q: %v", path, err) } trimmedPath := strings.TrimPrefix(filepath.Clean(path), root) if addToManaged { managed[trimmedPath] = true return nil } if _, ok := managed[trimmedPath]; ok { return nil } if !deleteFiles { log.Printf("INFO: Unmanaged file: %v\n", path) return nil } fullPath, err := fileutil.Expand(path) if err != nil { return err } log.Printf("WARNING: Deleting unmanaged file: %v\n", path) return os.RemoveAll(fullPath) } } // Walk the generated dir to find the managed files. if err := filepath.Walk(generatedDir, mkFn(true, generatedDir)); err != nil { return fmt.Errorf("walk %qs: %v", generatedDir, err) } // Walk the output dir to find the unmanaged files. if err := filepath.Walk(outputDir, mkFn(false, outputDir)); err != nil { return fmt.Errorf("walk %qs: %v", outputDir, err) } return nil } // trimDescriptions remove tabs and extra space from schema descriptions // so they can be used during rendering without unexpected gaps between lines. func trimDescriptions(schema map[string]interface{}) { val := reflect.ValueOf(schema) for _, key := range val.MapKeys() { v := val.MapIndex(key) switch t := v.Interface().(type) { case map[string]interface{}: trimDescriptions(t) case string: if key.String() == "description" { description := v.Interface().(string) trimmedDescription := strings.TrimSpace(leftTrim(description)) schema["description"] = trimmedDescription } } } } // leftTrim trims leading space from every line. func leftTrim(s string) string { var b strings.Builder for _, line := range strings.Split(s, "\n") { b.WriteString(strings.TrimLeft(line, " ")) b.WriteRune('\n') } return b.String() }