tpgtools/main.go (473 lines of code) (raw):
// Copyright 2021 Google LLC. All Rights Reserved.
//
// 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 main
import (
"bytes"
"errors"
"flag"
"fmt"
"io/ioutil"
"os"
"path"
"strings"
"text/template"
directory "github.com/GoogleCloudPlatform/declarative-resource-client-library/services"
"github.com/golang/glog"
"github.com/nasa9084/go-openapi"
"gopkg.in/yaml.v2"
)
var fPath = flag.String("path", "", "to be removed - path to the root service directory holding samples")
var tPath = flag.String("overrides", "", "path to the root directory holding overrides files")
var cPath = flag.String("handwritten", "handwritten", "path to the root directory holding handwritten files to copy")
var oPath = flag.String("output", "", "path to output generated files to")
var sFilter = flag.String("service", "", "optional service name. If specified, only this service is generated")
var rFilter = flag.String("resource", "", "optional resource name (from filename). If specified, only resources with this name are generated")
var vFilter = flag.String("version", "", "optional version name. If specified, this version is preferred for resource generation when applicable")
var mode = flag.String("mode", "", "mode for the generator. If unset, creates the provider. Options: 'serialization'")
var terraformResourceDirectory = "google-beta"
var terraformProviderModule = "github.com/hashicorp/terraform-provider-google-beta"
func main() {
resources, products, err := loadAndModelResources()
if err != nil {
glog.Exitf("Error loading resources: %w", err)
}
if mode != nil && *mode == "serialization" {
if vFilter != nil {
glog.Warning("[WARNING] serialization mode uses all resource versions. version flag is ignored")
}
generateSerializationLogic(resources)
return
}
var resourcesForVersion []*Resource
var productsForVersion []*ProductMetadata
var version *Version
if vFilter != nil && *vFilter != "" {
version = fromString(*vFilter)
if version == nil {
glog.Exitf("Failed finding version for input: %s", *vFilter)
}
resourcesForVersion = resources[*version]
productsForVersion = products[*version]
} else {
resourcesForVersion = resources[allVersions()[0]]
productsForVersion = products[allVersions()[0]]
}
if *version == GA_VERSION {
terraformResourceDirectory = "google"
terraformProviderModule = "github.com/hashicorp/terraform-provider-google/google"
} else if *version == ALPHA_VERSION {
terraformResourceDirectory = "google-private"
terraformProviderModule = "internal/terraform-next"
}
generatedResources := make([]*Resource, 0, len(resourcesForVersion))
for _, resource := range resourcesForVersion {
if skipResource(resource) {
continue
}
glog.Infof("Generating from resource %s", resource.TitleCaseFullName())
generateResourceFile(resource)
generateSweeperFile(resource)
generateResourceTestFile(resource)
generatedResources = append(generatedResources, resource)
}
generateProviderResourcesFile(generatedResources)
// GA website files are always generated for the beta version.
websiteVersion := *version
if *version == GA_VERSION {
websiteVersion = BETA_VERSION
}
for _, resource := range resources[websiteVersion] {
if skipResource(resource) {
continue
}
generateResourceWebsiteFile(resource, resources, version)
}
// product specific generation
generateProductsFile("provider_dcl_endpoints", productsForVersion)
generateProductsFile("provider_dcl_client_creation", productsForVersion)
if oPath == nil || *oPath == "" {
glog.Info("Skipping copying handwritten files, no output specified")
return
}
if cPath == nil || *cPath == "" {
glog.Info("No handwritten path specified")
return
}
// Copy DCL helper files into the folder tpgdclresource to make it easier to remove these files later.
dirPath := path.Join(*oPath, terraformResourceDirectory, "tpgdclresource")
if err := os.MkdirAll(dirPath, os.ModePerm); err != nil {
glog.Error(fmt.Errorf("error creating Terraform tpgdclresource directory %v: %v", dirPath, err))
}
}
func skipResource(r *Resource) bool {
// if a filter is specified, skip filtered services
if sFilter != nil && *sFilter != "" && DCLPackageName(*sFilter) != r.ProductMetadata().PackageName {
return true
}
// skip filtered resources
if rFilter != nil && *rFilter != "" && SnakeCaseTerraformResourceName(*rFilter) != r.Name() {
return true
}
// skip if set to SerializationOnly
return r.SerializationOnly
}
func loadAndModelResources() (map[Version][]*Resource, map[Version][]*ProductMetadata, error) {
flag.Parse()
if tPath == nil || *tPath == "" {
return nil, nil, errors.New("no path specified")
}
dirs, err := ioutil.ReadDir(*tPath)
if err != nil {
return nil, nil, err
}
resources := make(map[Version][]*Resource)
products := make(map[Version][]*ProductMetadata)
for _, version := range allVersions() {
resources[version] = make([]*Resource, 0)
for _, v := range dirs {
// skip flat files- we're looking for service directory
if !v.IsDir() {
continue
}
var overrideFiles []os.FileInfo
var packagePath Filepath
if version == GA_VERSION {
// GA has no separate directory
packagePath = Filepath(v.Name())
} else {
packagePath = Filepath(path.Join(v.Name(), version.V))
}
overrideFiles, err = ioutil.ReadDir(path.Join(*tPath, string(packagePath)))
var newResources []*Resource
// keep track of the last document in a service- we need one for the product later
var document *openapi.Document
for _, resourceFile := range overrideFiles {
if resourceFile.IsDir() || resourceFile.Name() == "tpgtools_product.yaml" {
continue
}
document = &openapi.Document{}
b := directory.Services().GetResource(version.V, v.Name(), stripExt(resourceFile.Name()))
if b == nil {
return nil, nil, fmt.Errorf("could not find resource in DCL directory: %q in %q at %q", stripExt(resourceFile.Name()), packagePath, version.V)
}
err = yaml.Unmarshal(b.Bytes(), document)
if err != nil {
return nil, nil, err
}
// TODO: the openapi library cannot handle extensions except in the Schema object. If this is ever added,
// this workaround can be removed.
if err := addInfoExtensionsToSchemaObjects(document, b.Bytes()); err != nil {
return nil, nil, err
}
overrides := loadOverrides(packagePath, resourceFile.Name())
if len(overrides) > 0 {
glog.Infof("Loaded overrides for %s", resourceFile.Name())
}
newResources = append(newResources, createResourcesFromDocumentAndOverrides(document, overrides, packagePath, version)...)
}
// if we found no resources, just keep going
if document == nil {
continue
}
products[version] = append(products[version], GetProductMetadataFromDocument(document, packagePath))
glog.Infof("Loaded product %s", packagePath)
resources[version] = append(resources[version], newResources...)
}
}
return resources, products, nil
}
func addInfoExtensionsToSchemaObjects(document *openapi.Document, b []byte) error {
var m map[string]interface{}
if err := yaml.Unmarshal(b, &m); err != nil {
return err
}
info := m["info"].(map[interface{}]interface{})
for _, s := range document.Components.Schemas {
s.Extension["x-dcl-ref"] = info["x-dcl-ref"]
s.Extension["x-dcl-guides"] = info["x-dcl-guides"]
}
return nil
}
func createResourcesFromDocumentAndOverrides(document *openapi.Document, overrides Overrides, packagePath Filepath, version Version) (resources []*Resource) {
productMetadata := GetProductMetadataFromDocument(document, packagePath)
titleParts := strings.Split(document.Info.Title, "/")
var schema *openapi.Schema
for k, v := range document.Components.Schemas {
if k == titleParts[len(titleParts)-1] {
schema = v
}
}
if schema == nil {
glog.Exit(fmt.Sprintf("Could not find document schema for %s", document.Info.Title))
}
if err := schema.Validate(); err != nil {
glog.Exit(err)
}
lRaw := schema.Extension["x-dcl-locations"]
var schemaLocations []interface{}
if lRaw == nil {
schemaLocations = make([]interface{}, 0)
} else {
schemaLocations = lRaw.([]interface{})
}
typeFetcher := NewTypeFetcher(document)
var locations []string
// If the schema cannot be split into two or more locations, we specify this
// by passing a single empty location string.
if len(schemaLocations) < 2 {
locations = make([]string, 1)
} else {
locations = make([]string, 0, len(schemaLocations))
for _, l := range schemaLocations {
locations = append(locations, l.(string))
}
}
for _, l := range locations {
res, err := createResource(schema, document.Info, typeFetcher, overrides, productMetadata, version, l)
if err != nil {
glog.Exit(err)
}
resources = append(resources, res)
}
return resources
}
// SerializationInput contains an array of resources along with additional generation metadata.
type SerializationInput struct {
Resources map[Version][]*Resource
Packages map[string]string
}
func generateSerializationLogic(specs map[Version][]*Resource) {
buf := bytes.Buffer{}
tmpl, err := template.New("serialization.go.tmpl").Funcs(TemplateFunctions).ParseFiles(
"templates/serialization.go.tmpl",
)
if err != nil {
glog.Exit(err)
}
packageMap := make(map[string]string)
for v, resList := range specs {
for _, res := range resList {
var pkgName, pkgPath string
pkgName = res.Package().lowercase() + v.SerializationSuffix
if v == GA_VERSION {
pkgPath = res.Package().lowercase()
} else {
pkgPath = path.Join(res.Package().lowercase(), v.V)
}
if _, ok := packageMap[pkgPath]; !ok {
packageMap[pkgName] = pkgPath
}
}
}
tmplInput := SerializationInput{
Resources: specs,
Packages: packageMap,
}
if err = tmpl.ExecuteTemplate(&buf, "serialization.go.tmpl", tmplInput); err != nil {
glog.Exit(err)
}
formatted, err := formatSource(&buf)
if err != nil {
glog.Error(fmt.Errorf("error formatting serialization logic: %v", err))
}
if oPath == nil || *oPath == "" {
fmt.Printf("%v", string(formatted))
} else {
err := ioutil.WriteFile(path.Join(*oPath, "serialization.go"), formatted, 0644)
if err != nil {
glog.Exit(err)
}
}
}
func loadOverrides(packagePath Filepath, fileName string) Overrides {
overrides := Overrides{}
if !(tPath == nil) && !(*tPath == "") {
b, err := ioutil.ReadFile(path.Join(*tPath, string(packagePath), fileName))
if err != nil {
// ignore the error if the file just doesn't exist
if !os.IsNotExist(err) {
glog.Exit(err)
}
} else {
err = yaml.UnmarshalStrict(b, &overrides)
if err != nil {
glog.Exit(err)
}
}
}
return overrides
}
func getParentDir(res *Resource) string {
servicePath := path.Join(*oPath, terraformResourceDirectory, "services", string(res.Package()))
if err := os.MkdirAll(servicePath, os.ModePerm); err != nil {
glog.Error(fmt.Errorf("error creating Terraform the service directory %v: %v", servicePath, err))
}
return servicePath
}
func generateResourceFile(res *Resource) {
// Generate resource file
tmplInput := ResourceInput{
Resource: *res,
}
tmpl, err := template.New("resource.go.tmpl").Funcs(TemplateFunctions).ParseFiles(
"templates/resource.go.tmpl",
)
if err != nil {
glog.Exit(err)
}
contents := bytes.Buffer{}
if err = tmpl.ExecuteTemplate(&contents, "resource.go.tmpl", tmplInput); err != nil {
glog.Exit(err)
}
if err != nil {
glog.Exit(err)
}
formatted, err := formatSource(&contents)
if err != nil {
glog.Error(fmt.Errorf("error formatting %v%v: %v - resource \n ", res.ProductName(), res.Name(), err))
}
if oPath == nil || *oPath == "" {
fmt.Printf("%v", string(formatted))
} else {
outname := fmt.Sprintf("resource_%s_%s.go", res.ProductName(), res.Name())
parentDir := getParentDir(res)
err = ioutil.WriteFile(path.Join(parentDir, outname), formatted, 0644)
if err != nil {
glog.Exit(err)
}
}
}
func generateSweeperFile(res *Resource) {
if !res.HasSweeper {
return
}
// Generate resource file
tmplInput := ResourceInput{
Resource: *res,
}
tmpl, err := template.New("sweeper.go.tmpl").Funcs(TemplateFunctions).ParseFiles(
"templates/sweeper.go.tmpl",
)
if err != nil {
glog.Exit(err)
}
contents := bytes.Buffer{}
if err = tmpl.ExecuteTemplate(&contents, "sweeper.go.tmpl", tmplInput); err != nil {
glog.Exit(err)
}
if err != nil {
glog.Exit(err)
}
formatted, err := formatSource(&contents)
if err != nil {
glog.Error(fmt.Errorf("error formatting %v%v: %v - sweeper", res.ProductName(), res.Name(), err))
}
if oPath == nil || *oPath == "" {
fmt.Printf("%v", string(formatted))
} else {
outname := fmt.Sprintf("resource_%s_%s_sweeper.go", res.ProductName(), res.Name())
parentDir := getParentDir(res)
err := ioutil.WriteFile(path.Join(parentDir, outname), formatted, 0644)
if err != nil {
glog.Exit(err)
}
}
}
func generateResourceTestFile(res *Resource) {
if len(res.TestSamples()) < 1 {
return
}
// Generate resource file
tmplInput := ResourceInput{
Resource: *res,
}
tmpl, err := template.New("test_file.go.tmpl").Funcs(TemplateFunctions).ParseFiles(
"templates/test_file.go.tmpl",
)
if err != nil {
glog.Exit(err)
}
contents := bytes.Buffer{}
if err = tmpl.ExecuteTemplate(&contents, "test_file.go.tmpl", tmplInput); err != nil {
fmt.Println(contents.String())
glog.Exit(err)
}
if err != nil {
glog.Exit(err)
}
formatted, err := formatSource(&contents)
if err != nil {
glog.Error(fmt.Errorf("error formatting %v%v: %v - test_file \n ", res.ProductName(), res.Name(), err))
}
if oPath == nil || *oPath == "" {
fmt.Printf("%v", string(formatted))
} else {
outname := fmt.Sprintf("resource_%s_%s_generated_test.go", res.ProductName(), res.Name())
parentDir := getParentDir(res)
err = ioutil.WriteFile(path.Join(parentDir, outname), formatted, 0644)
if err != nil {
glog.Exit(err)
}
}
}
func generateProviderResourcesFile(resources []*Resource) {
tmpl, err := template.New("provider_dcl_resources.go.tmpl").Funcs(TemplateFunctions).ParseFiles(
"templates/provider_dcl_resources.go.tmpl",
)
if err != nil {
glog.Exit(err)
}
contents := bytes.Buffer{}
if err = tmpl.ExecuteTemplate(&contents, "provider_dcl_resources.go.tmpl", resources); err != nil {
glog.Exit(err)
}
formatted, err := formatSource(&contents)
if err != nil {
glog.Error(fmt.Errorf("error formatting package provider_dcl_resource.go.tmpl file: \n%w", err))
}
if oPath == nil || *oPath == "" {
fmt.Print(string(formatted))
} else if err = ioutil.WriteFile(path.Join(*oPath, terraformResourceDirectory, "provider", "provider_dcl_resources.go"), formatted, 0644); err != nil {
glog.Exit(err)
}
}
func generateProductsFile(fileName string, products []*ProductMetadata) {
if len(products) <= 0 {
return
}
templateFileName := fileName + ".go.tmpl"
// Generate endpoints file
tmpl, err := template.New(templateFileName).Funcs(TemplateFunctions).ParseFiles(
"templates/" + templateFileName,
)
if err != nil {
glog.Exit(err)
}
contents := bytes.Buffer{}
if err = tmpl.ExecuteTemplate(&contents, templateFileName, products); err != nil {
glog.Exit(err)
}
formatted, err := formatSource(&contents)
if err != nil {
glog.Error(fmt.Errorf("error formatting package %s file: \n%w", fileName, err))
}
if oPath == nil || *oPath == "" {
fmt.Print(string(formatted))
} else {
outname := fileName + ".go"
DCLFolderPath := path.Join(*oPath, terraformResourceDirectory, "transport")
if err := os.MkdirAll(DCLFolderPath, os.ModePerm); err != nil {
glog.Error(fmt.Errorf("error creating Terraform DCL directory %v: %v", DCLFolderPath, err))
}
if err = ioutil.WriteFile(path.Join(DCLFolderPath, outname), formatted, 0644); err != nil {
glog.Exit(err)
}
}
}
var TemplateFunctions = template.FuncMap{
"title": strings.Title,
"patternToRegex": PatternToRegex,
"replace": strings.Replace,
"isLastIndex": isLastIndex,
"escapeDescription": escapeDescription,
"shouldAllowForwardSlashInFormat": shouldAllowForwardSlashInFormat,
}
// TypeFetcher fetches reused types, as marked by the $ref field being marked on an OpenAPI schema.
type TypeFetcher struct {
doc *openapi.Document
// Tracks if a property has already been generated.
generates map[string]string
}
// NewTypeFetcher returns a TypeFetcher for a OpenAPI document.
func NewTypeFetcher(doc *openapi.Document) *TypeFetcher {
return &TypeFetcher{
doc: doc,
generates: make(map[string]string),
}
}
// ResolveSchema resolves a #/components/schemas reference from a reused type.
func (r *TypeFetcher) ResolveSchema(ref string) (*openapi.Schema, error) {
return openapi.ResolveSchema(r.doc, ref)
}
// PackagePathForReference returns either the packageName or the shared package name.
func (r *TypeFetcher) PackagePathForReference(ref, packageName string) string {
if v, ok := r.generates[ref]; ok {
return v
} else {
r.generates[ref] = packageName
return packageName
}
}