tpgtools/sample.go (411 lines of code) (raw):
package main
import (
"fmt"
"io/ioutil"
"path"
"regexp"
"strings"
dcl "github.com/GoogleCloudPlatform/declarative-resource-client-library/dcl"
"github.com/golang/glog"
)
// Sample is the object containing sample data from DCL samples
type Sample struct {
// Name of the file the sample was loaded from
FileName string
// Name is the name of a sample
Name *string
// Description is a short description of the sample
Description *string
// DependencyFileNames contains the filenames of every resource in the sample
DependencyFileNames []string `yaml:"dependencies"`
// PrimaryResource is the filename of the sample's primary resource
PrimaryResource *string `yaml:"resource"`
IgnoreRead []string `yaml:"ignore_read"`
// DependencyList is a list of objects containing metadata for each sample resource
DependencyList []Dependency
// The name of the test
TestSlug RenderedString
// The raw versions stated in the yaml
Versions []string
// A list of updates that the resource can transition between
Updates []Update
// HasGAEquivalent tells us if we should have `provider = google-beta`
// in the testcase. (if the test doesn't have a ga version of the test)
HasGAEquivalent bool
// LongForm is whether this sample is a copy with long form fields expanded to include `/`
LongForm bool
// SamplesPath is the path to the directory where the original sample data is stored
SamplesPath Filepath
// resourceReference is the resource the sample belongs to
resourceReference *Resource
// CustomCheck allows you to add a terraform check function to all tests
CustomCheck []string `yaml:"check"`
// CodeInject references reletive raw files that should be injected into the sample test
CodeInject []string `yaml:"code_inject"`
// DocHide specifies a list of samples to hide from docs
DocHide []string `yaml:"doc_hide"`
// DocHideConditional specifies a list of samples to hide from docs when resource location matches
DocHideConditional []DocHideCondition `yaml:"doc_hide_conditional"`
// Testhide specifies a list of samples to hide from tests
Testhide []string `yaml:"test_hide"`
// TesthideConditional specifies a list of samples to hide from tests when resource location matches
TestHideConditional []TestHideCondition `yaml:"test_hide_conditional"`
// ExtraDependencies are the additional golang dependencies the injected code may require
ExtraDependencies []string `yaml:"extra_dependencies"`
// ExternalProviders are the external providers needed for tests
ExternalProviders []string `yaml:"external_providers"`
// Type is the resource type.
Type string `yaml:"type"`
// Variables are the various attributes of the set of resources that need to be filled in.
Variables []Variable `yaml:"variables"`
}
// Variable contains metadata about the types of variables in a sample.
type Variable struct {
// Name is the variable name in the JSON.
Name string `yaml:"name"`
// Type is the variable type.
Type string `yaml:"type"`
// DocsValue is an optional value that should be substituted directly into
// the documentation for this variable. If not provided, tpgtools makes
// its best guess about a suitable value. Generally, this is only provided
// if the "best guess" is a poor one.
DocsValue string `yaml:"docs_value"`
}
type DocHideCondition struct {
// Location is the location attribute to match, if matched, append Name to list of DocHide
Location string `yaml:"location"`
// Name specifies sample file name to add to DocHide if location matches.
Name string `yaml:"file_name"`
}
type TestHideCondition struct {
// Location is the location attribute to match, if matched, append Name to list of Testhide
Location string `yaml:"location"`
// Name specifies sample file name to add to Testhide if location matches.
Name string `yaml:"file_name"`
}
// Dependency contains data that describes a single resource in a sample
type Dependency struct {
// FileName is the name of the file as it appears in testcases.yaml
FileName string
// HCLLocalName is the local name of the HCL block, e.g. "basic" or "default"
HCLLocalName string
// TerraformResourceType is the type represented in Terraform, e.g. "google_compute_instance"
TerraformResourceType string
// HCLBlock is the snippet of HCL config that declares this resource
HCLBlock string // Path to the directory where the sample data is stored
}
type Update struct {
// The list of dependency resources to update.
Dependencies []string `yaml:"dependencies"`
// The resource to update.
Resource string `yaml:"resource"`
}
func packageNameFromFilepath(fp Filepath, product SnakeCaseProductName) (DCLPackageName, error) {
pm := NewProductMetadata(fp, string(product))
return pm.PackageName, nil
}
func findDCLReferencePackage(product SnakeCaseProductName) (DCLPackageName, error) {
// Most packages are just the product name with all the underscores removed.
// Try that first.
// We can check if a package exists by checking the "productOverrides" map from product.go, which
// will be populated by this point. That takes a "Filepath", because the reference is to the
// actual name of the directory that contains the overrides - by mandate, that's the same as the
// dcl package name, so this conversion happens to work out.
possibleFilepath := Filepath(strings.ReplaceAll(string(product), "_", ""))
if _, ok := productOverrides[possibleFilepath]; ok {
return packageNameFromFilepath(possibleFilepath, product)
}
baseFilepath := possibleFilepath
possibleFilepath = Filepath(string(baseFilepath) + "/beta")
if _, ok := productOverrides[possibleFilepath]; ok {
return packageNameFromFilepath(possibleFilepath, product)
}
possibleFilepath = Filepath(string(baseFilepath) + "/alpha")
if _, ok := productOverrides[possibleFilepath]; ok {
return packageNameFromFilepath(possibleFilepath, product)
}
// Otherwise, just return an error.
var productOverrideKeys []Filepath
for k := range productOverrides {
productOverrideKeys = append(productOverrideKeys, k)
}
return DCLPackageName(""), fmt.Errorf("can't find %q in the overrides map, which contains %v", product, productOverrideKeys)
}
// BuildDependency produces a Dependency using a file and filename
func BuildDependency(fileName string, product SnakeCaseProductName, localname, version string, hasGAEquivalent, makeLongForm bool, b []byte) (*Dependency, error) {
// Miscellaneous name rather than "resource name" because this is the name in the sample json file - which might not match the TF name!
// we have to account for that.
var resourceName miscellaneousNameSnakeCase
var packageName DCLPackageName
fileParts := strings.Split(fileName, ".")
if len(fileParts) == 4 {
p, rn := fileParts[1], fileParts[2]
packageName = DCLPackageName(p)
resourceName = miscellaneousNameSnakeCase(rn)
} else if len(fileParts) == 3 {
resourceName = miscellaneousNameSnakeCase(fileParts[1])
var err error
packageName, err = findDCLReferencePackage(product)
if err != nil {
return nil, err
}
} else {
return nil, fmt.Errorf("Invalid sample dependency file name: %s", fileName)
}
if localname == "" {
localname = fileParts[0]
}
terraformResourceType, err := DCLToTerraformReference(packageName, resourceName, version)
if err != nil {
return nil, fmt.Errorf("Error generating sample dependency reference %s: %s", fileName, err)
}
block, err := ConvertSampleJSONToHCL(packageName, resourceName, version, hasGAEquivalent, makeLongForm, b)
if err != nil {
glog.Errorf("failed to convert %q", fileName)
return nil, fmt.Errorf("Error generating sample dependency %s: %s", fileName, err)
}
// Find all instances of `resource "foo" "bar"` and replace `bar` with localname.
re := regexp.MustCompile(`(resource "` + terraformResourceType + `" ")(\w*)`)
block = re.ReplaceAllString(block, "${1}"+localname)
d := &Dependency{
FileName: fileName,
HCLLocalName: localname,
TerraformResourceType: terraformResourceType,
HCLBlock: block,
}
return d, nil
}
func (s *Sample) generateSampleDependency(fileName string) Dependency {
return s.generateSampleDependencyWithName(fileName, "")
}
func (s *Sample) generateSampleDependencyWithName(fileName, localname string) Dependency {
dFileNameParts := strings.Split(fileName, "samples/")
fileName = dFileNameParts[len(dFileNameParts)-1]
dependencyBytes, err := ioutil.ReadFile(path.Join(string(s.SamplesPath), fileName))
version := s.resourceReference.versionMetadata.V
product := s.resourceReference.productMetadata.ProductName
d, err := BuildDependency(fileName, product, localname, version, s.HasGAEquivalent, s.LongForm, dependencyBytes)
if err != nil {
glog.Exit(err)
}
return *d
}
func (s *Sample) GetCodeToInject() []string {
sampleAccessoryFolder := s.resourceReference.getSampleAccessoryFolder()
var out []string
for _, fileName := range s.CodeInject {
filePath := path.Join(string(sampleAccessoryFolder), fileName)
tc, err := ioutil.ReadFile(filePath)
if err != nil {
glog.Exit(err)
}
out = append(out, string(tc))
}
return out
}
// ReplaceReferences substitutes any reference tags for their HCL address
// This should only be called after every dependency for a sample is built
func (s Sample) ReplaceReferences(d *Dependency) error {
re := regexp.MustCompile(`"?{{\s*ref:([a-z_]*\.[a-z_]*\.[a-z_]*(?:\.[a-z_]*)?):([a-zA-Z0-9_\.\[\]]*)\s*}}"?`)
matches := re.FindAllStringSubmatch(d.HCLBlock, -1)
for _, match := range matches {
referenceFileName := match[1]
idField := dcl.TitleToSnakeCase(match[2])
var tfReference string
for _, dep := range s.DependencyList {
if dep.FileName == referenceFileName {
tfReference = dep.TerraformResourceType + "." + dep.HCLLocalName + "." + idField
break
}
}
if tfReference == "" {
return fmt.Errorf("Could not find reference file name: %s", referenceFileName)
}
startsWithQuote := strings.HasPrefix(match[0], `"`)
endsWithQuote := strings.HasSuffix(match[0], `"`)
if !(startsWithQuote && endsWithQuote) {
tfReference = fmt.Sprintf("${%s}", tfReference)
if startsWithQuote {
tfReference = `"` + tfReference
}
if endsWithQuote {
tfReference += `"`
}
}
d.HCLBlock = strings.Replace(d.HCLBlock, match[0], tfReference, 1)
}
return nil
}
func (s Sample) generateHCLTemplate() (string, error) {
if len(s.DependencyList) == 0 {
return "", fmt.Errorf("Could not generate HCL template for %s: there are no dependencies", *s.Name)
}
var hcl string
for index := range s.DependencyList {
err := s.ReplaceReferences(&s.DependencyList[index])
if err != nil {
return "", fmt.Errorf("Could not generate HCL template for %s: %s", *s.Name, err)
}
hcl = fmt.Sprintf("%s%s\n", hcl, s.DependencyList[index].HCLBlock)
}
return hcl, nil
}
// GenerateHCL generates sample HCL using docs substitution metadata
func (s Sample) GenerateHCL(isDocs bool) string {
var hcl string
var err error
if !s.isNativeHCL() {
hcl, err = s.generateHCLTemplate()
if err != nil {
glog.Exit(err)
}
} else {
tc, err := ioutil.ReadFile(path.Join(string(s.SamplesPath), *s.PrimaryResource))
if err != nil {
glog.Exit(err)
}
hcl = string(tc)
}
for _, sub := range s.Variables {
re := regexp.MustCompile(fmt.Sprintf(`{{%s}}`, sub.Name))
hcl = re.ReplaceAllString(hcl, sub.translateValue(isDocs))
}
return hcl
}
// isNativeHCL returns whether the resource file is terraform synatax
func (s Sample) isNativeHCL() bool {
return strings.HasSuffix(*s.PrimaryResource, ".tf.tmpl")
}
// EnumerateWithUpdateSamples returns an array of new samples expanded with
// any subsequent samples
func (s *Sample) EnumerateWithUpdateSamples() []Sample {
out := []Sample{*s}
for i, update := range s.Updates {
newSample := *s
primaryResource := update.Resource
// TODO(magic-modules-eng): Consume new dependency list.
newSample.PrimaryResource = &primaryResource
if !newSample.isNativeHCL() {
var newDeps []Dependency
newDeps = append(newDeps, newSample.generateSampleDependencyWithName(*newSample.PrimaryResource, "primary"))
for _, newDepFilename := range update.Dependencies {
newDepFilename = strings.TrimPrefix(newDepFilename, "samples/")
newDeps = append(newDeps, newSample.generateSampleDependencyWithName(newDepFilename, basicResourceName(newDepFilename)))
}
newSample.DependencyList = newDeps
}
newSample.TestSlug = RenderedString(fmt.Sprintf("%sUpdate%v", newSample.TestSlug, i))
newSample.Updates = nil
newSample.Variables = s.Variables
out = append(out, newSample)
}
return out
}
func basicResourceName(depFilename string) string {
re := regexp.MustCompile("^update(_\\d)?\\.")
// update_1.resource.json -> basic.resource.json
basicReplaced := re.ReplaceAllString(depFilename, "basic.")
re = regexp.MustCompile("^update(_\\d)?_")
// update_1_name.resource.json -> name.resource.json
prefixTrimmed := re.ReplaceAllString(basicReplaced, "")
return dcl.SnakeToJSONCase(strings.Split(prefixTrimmed, ".")[0])
}
// ExpandContext expands the context model used in the generated tests
func (s Sample) ExpandContext() map[string]string {
out := map[string]string{}
for _, sub := range s.Variables {
translation, hasTranslation := translationMap[sub.Type]
if hasTranslation {
out[translation.contextKey] = translation.contextValue
}
}
return out
}
type translationIndex struct {
docsValue string
contextKey string
contextValue string
}
var translationMap = map[string]translationIndex{
"org_id": {
docsValue: "123456789",
contextKey: "org_id",
contextValue: "envvar.GetTestOrgFromEnv(t)",
},
"org_name": {
docsValue: "example.com",
contextKey: "org_domain",
contextValue: "envvar.GetTestOrgDomainFromEnv(t)",
},
"region": {
docsValue: "us-west1",
contextKey: "region",
contextValue: "envvar.GetTestRegionFromEnv()",
},
"zone": {
docsValue: "us-west1-a",
contextKey: "zone",
contextValue: "envvar.GetTestZoneFromEnv()",
},
"org_target": {
docsValue: "123456789",
contextKey: "org_target",
contextValue: "envvar.GetTestOrgTargetFromEnv(t)",
},
"billing_account": {
docsValue: "000000-0000000-0000000-000000",
contextKey: "billing_acct",
contextValue: "envvar.GetTestBillingAccountFromEnv(t)",
},
"test_service_account": {
docsValue: "my@service-account.com",
contextKey: "service_acct",
contextValue: "envvar.GetTestServiceAccountFromEnv(t)",
},
"project": {
docsValue: "my-project-name",
contextKey: "project_name",
contextValue: "envvar.GetTestProjectFromEnv()",
},
"project_number": {
docsValue: "my-project-number",
contextKey: "project_number",
contextValue: "envvar.GetTestProjectNumberFromEnv()",
},
"customer_id": {
docsValue: "A01b123xz",
contextKey: "cust_id",
contextValue: "envvar.GetTestCustIdFromEnv(t)",
},
// Begin a long list of multicloud-only values which are not going to see reuse.
// We can hardcode fake values because we are
// always going to use the no-provisioning mode for unit testing, of these resources
// where we don't have to actually have a real AWS account.
"aws_account_id": {
docsValue: "012345678910",
contextKey: "aws_acct_id",
contextValue: `"111111111111"`,
},
"aws_database_encryption_key": {
docsValue: "12345678-1234-1234-1234-123456789111",
contextKey: "aws_db_key",
contextValue: `"00000000-0000-0000-0000-17aad2f0f61f"`,
},
"aws_region": {
docsValue: "my-aws-region",
contextKey: "aws_region",
contextValue: `"us-west-2"`,
},
"aws_security_group": {
docsValue: "sg-00000000000000000",
contextKey: "aws_sg",
contextValue: `"sg-0b3f63cb91b247628"`,
},
"aws_volume_encryption_key": {
docsValue: "12345678-1234-1234-1234-123456789111",
contextKey: "aws_vol_key",
contextValue: `"00000000-0000-0000-0000-17aad2f0f61f"`,
},
"aws_vpc": {
docsValue: "vpc-00000000000000000",
contextKey: "aws_vpc",
contextValue: `"vpc-0b3f63cb91b247628"`,
},
"aws_subnet": {
docsValue: "subnet-00000000000000000",
contextKey: "aws_subnet",
contextValue: `"subnet-0b3f63cb91b247628"`,
},
"azure_application": {
docsValue: "12345678-1234-1234-1234-123456789111",
contextKey: "azure_app",
contextValue: `"00000000-0000-0000-0000-17aad2f0f61f"`,
},
"azure_subscription": {
docsValue: "12345678-1234-1234-1234-123456789111",
contextKey: "azure_sub",
contextValue: `"00000000-0000-0000-0000-17aad2f0f61f"`,
},
"azure_ad_tenant": {
docsValue: "12345678-1234-1234-1234-123456789111",
contextKey: "azure_tenant",
contextValue: `"00000000-0000-0000-0000-17aad2f0f61f"`,
},
"azure_proxy_config_secret_version": {
docsValue: "0000000000000000000000000000000000",
contextKey: "azure_config_secret",
contextValue: `"07d4b1f1a7cb4b1b91f070c30ae761a1"`,
},
"byo_multicloud_prefix": {
docsValue: "my-",
contextKey: "byo_prefix",
contextValue: `"mmv2"`,
},
}
// translateValue returns the value to embed in the hcl
func (sub *Variable) translateValue(isDocs bool) string {
value := sub.Name
translation, hasTranslation := translationMap[sub.Type]
if isDocs {
if sub.DocsValue != "" {
return sub.DocsValue
}
if hasTranslation {
return translation.docsValue
}
if sub.Type != "resource_name" {
glog.Exitf("Cannot generate docs for variable of type %q.", sub.Type)
}
return value
}
if hasTranslation {
return fmt.Sprintf("%%{%s}", translation.contextKey)
}
if sub.Type != "resource_name" {
glog.Exitf("Cannot generate sample test with variable of type %q - please add to sample.go's translationMap.", sub.Type)
}
// Use '_' if already present, or '-' otherwise (some APIs require '-').
if strings.Contains(value, "_") {
value = fmt.Sprintf("tf_test_%s", value)
} else {
value = fmt.Sprintf("tf-test-%s", value)
}
// Random suffix is 10 characters and standard name length <= 64
if len(value) > 54 {
value = value[:54]
}
return fmt.Sprintf("%s%%{random_suffix}", value)
}
func (s Sample) PrimaryResourceName() string {
fileParts := strings.Split(*s.PrimaryResource, ".")
return fileParts[0]
}