Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add example manifest generation pipeline #4

Merged
merged 7 commits into from
Jun 9, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ require (
k8s.io/apimachinery v0.24.0
k8s.io/utils v0.0.0-20220210201930-3a6ce19ff2f9
sigs.k8s.io/controller-runtime v0.12.1
sigs.k8s.io/yaml v1.3.0
)

require (
Expand Down Expand Up @@ -127,5 +128,4 @@ require (
k8s.io/kube-openapi v0.0.0-20220328201542-3ee0da9b0b42 // indirect
sigs.k8s.io/json v0.0.0-20211208200746-9f7c6b3444d2 // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.2.1 // indirect
sigs.k8s.io/yaml v1.3.0 // indirect
)
21 changes: 12 additions & 9 deletions pkg/config/provider.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
/*
Copyright 2022 Upbound Inc.
*/

package config

import (
Expand Down Expand Up @@ -96,6 +100,10 @@ type Provider struct {
// resource name.
Resources map[string]*Resource

// ProviderMetadataPath is the scraped provider metadata file path
// from Terraform registry
ProviderMetadataPath string

// resourceConfigurators is a map holding resource configurators where key
// is Terraform resource name.
resourceConfigurators map[string]ResourceConfiguratorChain
Expand Down Expand Up @@ -146,10 +154,10 @@ func WithDefaultResourceFn(f DefaultResourceFn) ProviderOption {
}
}

// NewProviderWithSchema builds and returns a new Provider from provider
// NewProvider builds and returns a new Provider from provider
// tfjson schema, that is generated using Terraform CLI with:
// `terraform providers schema --json`
func NewProviderWithSchema(schema []byte, prefix string, modulePath string, opts ...ProviderOption) *Provider {
func NewProvider(schema []byte, prefix string, modulePath string, metadataPath string, opts ...ProviderOption) *Provider {
ps := tfjson.ProviderSchemas{}
if err := ps.UnmarshalJSON(schema); err != nil {
panic(err)
Expand All @@ -162,13 +170,8 @@ func NewProviderWithSchema(schema []byte, prefix string, modulePath string, opts
rs = v.ResourceSchemas
break
}
return NewProvider(conversiontfjson.GetV2ResourceMap(rs), prefix, modulePath, opts...)
}

// NewProvider builds and returns a new Provider.
// Deprecated: This function will be removed soon, please use
// NewProviderWithSchema instead.
func NewProvider(resourceMap map[string]*schema.Resource, prefix string, modulePath string, opts ...ProviderOption) *Provider {
resourceMap := conversiontfjson.GetV2ResourceMap(rs)
p := &Provider{
ModulePath: modulePath,
TerraformResourcePrefix: fmt.Sprintf("%s_", prefix),
Expand All @@ -181,6 +184,7 @@ func NewProvider(resourceMap map[string]*schema.Resource, prefix string, moduleP
".+",
},
Resources: map[string]*Resource{},
ProviderMetadataPath: metadataPath,
resourceConfigurators: map[string]ResourceConfiguratorChain{},
}

Expand All @@ -204,7 +208,6 @@ func NewProvider(resourceMap map[string]*schema.Resource, prefix string, moduleP

p.Resources[name] = p.DefaultResourceFn(name, terraformResource)
}

return p
}

Expand Down
3 changes: 3 additions & 0 deletions pkg/pipeline/crd.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ type CRDGenerator struct {
Group string
ProviderShortName string
LicenseHeaderPath string
Generated *tjtypes.Generated

pkg *types.Package
}
Expand All @@ -65,6 +66,8 @@ func (cg *CRDGenerator) Generate(cfg *config.Resource) (string, error) {
if err != nil {
return "", errors.Wrapf(err, "cannot build types for %s", cfg.Kind)
}
cg.Generated = &gen

// TODO(muvaf): TypePrinter uses the given scope to see if the type exists
// before printing. We should ideally load the package in file system but
// loading the local package will result in error if there is
Expand Down
301 changes: 301 additions & 0 deletions pkg/pipeline/example.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,301 @@
/*
Copyright 2022 Upbound Inc.
*/

package pipeline

import (
"fmt"
"io/ioutil"
"os"
"path/filepath"
"regexp"
"strings"

"github.com/crossplane/crossplane-runtime/pkg/fieldpath"
"github.com/pkg/errors"
"sigs.k8s.io/yaml"

"github.com/upbound/upjet/pkg/config"
"github.com/upbound/upjet/pkg/resource/json"
tjtypes "github.com/upbound/upjet/pkg/types"
)

var (
reRef = regexp.MustCompile(`\${(.+)}`)
reFile = regexp.MustCompile(`file\("(.+)"\)`)
)

type pavedWithManifest struct {
manifestPath string
paved *fieldpath.Paved
refsResolved bool
}

// ResourceExample represents the scraped example HCL configuration
// for a Terraform resource
type ResourceExample struct {
Manifest string `yaml:"manifest"`
References map[string]string `yaml:"references,omitempty"`
}

// Resource represents the scraped metadata for a Terraform resource
type Resource struct {
SubCategory string `yaml:"subCategory"`
Description string `yaml:"description,omitempty"`
Name string `yaml:"name"`
TitleName string `yaml:"titleName"`
Examples []ResourceExample `yaml:"examples,omitempty"`
ArgumentDocs map[string]string `yaml:"argumentDocs"`
ImportStatements []string `yaml:"importStatements"`
}

// ProviderMetadata metadata for a Terraform native provider
type ProviderMetadata struct {
Name string `yaml:"name"`
Resources map[string]*Resource `yaml:"resources"`
}

// NewProviderMetadataFromFile loads metadata from the specified YAML-formatted file
func NewProviderMetadataFromFile(path string) (*ProviderMetadata, error) {
buff, err := ioutil.ReadFile(filepath.Clean(path))
if err != nil {
return nil, errors.Wrapf(err, "failed to read metadata file %q", path)
}

metadata := &ProviderMetadata{}
return metadata, errors.Wrap(yaml.Unmarshal(buff, metadata), "failed to unmarshal provider metadata")
}

// ExampleGenerator represents a pipeline for generating example manifests.
// Generates example manifests for Terraform resources under examples-generated.
type ExampleGenerator struct {
rootDir string
resourceMeta map[string]*Resource
resources map[string]*pavedWithManifest
}

// NewExampleGenerator returns a configured ExampleGenerator
func NewExampleGenerator(rootDir string, resourceMeta map[string]*Resource) *ExampleGenerator {
return &ExampleGenerator{
rootDir: rootDir,
resourceMeta: resourceMeta,
resources: make(map[string]*pavedWithManifest),
}
}

// StoreExamples stores the generated example manifests under examples-generated in
// their respective API groups.
func (eg *ExampleGenerator) StoreExamples() error {
for n, pm := range eg.resources {
if err := eg.resolveReferencesOfPaved(pm); err != nil {
return errors.Wrapf(err, "cannot resolve references for resource: %s", n)
}
u := pm.paved.UnstructuredContent()
delete(u["spec"].(map[string]interface{})["forProvider"].(map[string]interface{}), "depends_on")
buff, err := yaml.Marshal(u)
if err != nil {
return errors.Wrapf(err, "cannot marshal example manifest for resource: %s", n)
}
manifestDir := filepath.Dir(pm.manifestPath)
if err := os.MkdirAll(manifestDir, 0750); err != nil {
return errors.Wrapf(err, "cannot mkdir %s", manifestDir)
}
// no sensitive info in the example manifest
if err := ioutil.WriteFile(pm.manifestPath, buff, 0644); err != nil { // nolint:gosec
return errors.Wrapf(err, "cannot write example manifest file %s for resource %s", pm.manifestPath, n)
}
}
return nil
}

func (eg *ExampleGenerator) resolveReferencesOfPaved(pm *pavedWithManifest) error {
if pm.refsResolved {
return nil
}
pm.refsResolved = true
return errors.Wrap(eg.resolveReferences(pm.paved.UnstructuredContent()), "failed to resolve references of paved")
}

func (eg *ExampleGenerator) resolveReferences(params map[string]interface{}) error { // nolint:gocyclo
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are two classes of references in the examples from what I've seen:

  • Referencing of a static value, i.e. location from ResourceGroup
  • Referencing the actual object, i.e. ID of ResourceGroup

We're resolving references only for the static values here, right? There isn't any code here that will infer actual object reference that could potentially populate References configuration field?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the context here is to generate the example manifests and the sort of reference-resolving we do here result in segments like:

   - connectionStringSecretRef:
        key: attribute.primary_connection_string
        name: example-eventhub-authorization-rule
        namespace: crossplane-system
...
    masterPasswordSecretRef:
      key: example-key
      name: example-secret
      namespace: crossplane-system
    resourceGroupNameRef:
      name: example
    serverNameRef:
      name: example

where in the last fragment, serverNameRef is a reference to a PostgreSQL server resource.

For automatically populating referencer fields from the provided example HCL configurations, the proposed metadata document format contains the examples[].references array.

In this implementation, we do not use this metadata extracted from example HCL configurations to generate cross-resource referencer fields. But as an example we have something like:

             ...
             references:
                backup_policy_id: azurerm_data_protection_backup_policy_postgresql.id
                database_credential_key_vault_secret_id: azurerm_key_vault_secret.versionless_id
                database_id: azurerm_postgresql_database.id
                location: azurerm_resource_group.location
                vault_id: azurerm_data_protection_backup_vault.id

for the azurerm_data_protection_backup_instance_postgresql resource extracted as metadata.

for k, v := range params {
switch t := v.(type) {
case map[string]interface{}:
if err := eg.resolveReferences(t); err != nil {
return err
}

case []interface{}:
for _, e := range t {
eM, ok := e.(map[string]interface{})
if !ok {
continue
}
if err := eg.resolveReferences(eM); err != nil {
return err
}
}

case string:
g := reRef.FindStringSubmatch(t)
if len(g) != 2 {
continue
}
path := strings.Split(g[1], ".")
// expected reference format is <resource type>.<resource name>.<field name>
if len(path) < 3 {
continue
}
pm := eg.resources[path[0]]
if pm == nil || pm.paved == nil {
continue
}
if err := eg.resolveReferencesOfPaved(pm); err != nil {
return errors.Wrapf(err, "cannot recursively resolve references for %q", path[0])
}
pathStr := strings.Join(append([]string{"spec", "forProvider"}, path[2:]...), ".")
s, err := pm.paved.GetString(pathStr)
if fieldpath.IsNotFound(err) {
continue
}
if err != nil {
return errors.Wrapf(err, "cannot get string value from paved: %s", pathStr)
}
params[k] = s
}
}
return nil
}

// Generate generates an example manifest for the specified Terraform resource.
func (eg *ExampleGenerator) Generate(group, version string, r *config.Resource, fieldTransformations map[string]tjtypes.Transformation) error {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to confirm, we have Generate and StoreExamples separate because of the global catalog approach, right? Once we move to scoping references of an example to its own HCL block, we could be done with a single Generate function like others?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, currently we resolve any references globally so we store example manifests with their references resolved after we have generated CRDs for all resources.

For test automation, when we switch to an implementation in which we use example manifests for the dependencies specified together with the target resource, we may start resolving references locally ("local resolution"). But this may not hold for all resources, i.e., for some resources, Terraform registry may not contain example manifests for the dependencies locally (specified together with the target resource's manifest). Thus, first doing the resolution locally and then globally (prioritizing local resolution over global resolution but also keeping global resolution) would provide better coverage potentially requiring less manual configuration. Let's think about this in a future PR implementing local resolution.

rm := eg.resourceMeta[r.Name]
if rm == nil || len(rm.Examples) == 0 {
return nil
}
var exampleParams map[string]interface{}
if err := json.TFParser.Unmarshal([]byte(rm.Examples[0].Manifest), &exampleParams); err != nil {
return errors.Wrapf(err, "cannot unmarshal example manifest for resource: %s", r.Name)
}
transformFields(exampleParams, r.ExternalName.OmittedFields, fieldTransformations, "")

example := map[string]interface{}{
"apiVersion": fmt.Sprintf("%s/%s", group, version),
"kind": r.Kind,
"metadata": map[string]interface{}{
"name": "example",
},
"spec": map[string]interface{}{
"forProvider": exampleParams,
"providerConfigRef": map[string]interface{}{
"name": "example",
},
},
}
manifestDir := filepath.Join(eg.rootDir, "examples-generated", strings.ToLower(strings.Split(group, ".")[0]))
eg.resources[r.Name] = &pavedWithManifest{
manifestPath: filepath.Join(manifestDir, fmt.Sprintf("%s.yaml", strings.ToLower(r.Kind))),
paved: fieldpath.Pave(example),
}
return nil
}

func getHierarchicalName(prefix, name string) string {
if prefix == "" {
return name
}
return fmt.Sprintf("%s.%s", prefix, name)
}

func transformFields(params map[string]interface{}, omittedFields []string, t map[string]tjtypes.Transformation, namePrefix string) { // nolint:gocyclo
for _, hn := range omittedFields {
for n := range params {
if hn == getHierarchicalName(namePrefix, n) {
delete(params, n)
break
}
}
}

for n, v := range params {
switch pT := v.(type) {
case map[string]interface{}:
transformFields(pT, omittedFields, t, getHierarchicalName(namePrefix, n))

case []interface{}:
for _, e := range pT {
eM, ok := e.(map[string]interface{})
if !ok {
continue
}
transformFields(eM, omittedFields, t, getHierarchicalName(namePrefix, n))
}
}
}

for hn, transform := range t {
for n, v := range params {
if hn == getHierarchicalName(namePrefix, n) {
delete(params, n)
if transform.IsRef {
if !transform.IsSensitive {
params[transform.TransformedName] = getRefField(v,
map[string]interface{}{
"name": "example",
})
} else {
secretName, secretKey := getSecretRef(v)
params[transform.TransformedName] = getRefField(v,
map[string]interface{}{
"name": secretName,
"namespace": "crossplane-system",
"key": secretKey,
})
}
} else {
params[transform.TransformedName] = v
}
break
}
}
}
}

func getRefField(v interface{}, ref map[string]interface{}) interface{} {
switch v.(type) {
case []interface{}:
return []interface{}{
ref,
}

default:
return ref
}
}

func getSecretRef(v interface{}) (string, string) {
secretName := "example-secret"
secretKey := "example-key"
s, ok := v.(string)
if !ok {
return secretName, secretKey
}
g := reRef.FindStringSubmatch(s)
if len(g) != 2 {
return secretName, secretKey
}
f := reFile.FindStringSubmatch(g[1])
switch {
case len(f) == 2: // then a file reference
_, file := filepath.Split(f[1])
secretKey = fmt.Sprintf("attribute.%s", file)
default:
parts := strings.Split(g[1], ".")
if len(parts) < 3 {
return secretName, secretKey
}
secretName = fmt.Sprintf("example-%s", strings.Join(strings.Split(parts[0], "_")[1:], "-"))
secretKey = fmt.Sprintf("attribute.%s", strings.Join(parts[2:], "."))
}
return secretName, secretKey
}
Loading