Skip to content

Commit

Permalink
feat: Add a command to automatically generate type info for output in…
Browse files Browse the repository at this point in the history
… metadata file (#2602)
  • Loading branch information
tjy9206 authored Oct 1, 2024
1 parent 51dc21f commit 925bd48
Show file tree
Hide file tree
Showing 7 changed files with 461 additions and 9 deletions.
26 changes: 18 additions & 8 deletions cli/bpmetadata/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,13 @@ import (
)

var mdFlags struct {
path string
nested bool
force bool
display bool
validate bool
quiet bool
path string
nested bool
force bool
display bool
validate bool
quiet bool
genOutputType bool
}

const (
Expand All @@ -49,6 +50,7 @@ func init() {
Cmd.Flags().BoolVar(&mdFlags.nested, "nested", true, "Flag for generating metadata for nested blueprint, if any.")
Cmd.Flags().BoolVarP(&mdFlags.validate, "validate", "v", false, "Validate metadata against the schema definition.")
Cmd.Flags().BoolVarP(&mdFlags.quiet, "quiet", "q", false, "Run in quiet mode suppressing all prompts.")
Cmd.Flags().BoolVarP(&mdFlags.genOutputType, "generate-output-type", "g", false, "Automatically generate type field for outputs.")
}

var Cmd = &cobra.Command{
Expand Down Expand Up @@ -148,6 +150,14 @@ func generateMetadataForBpPath(bpPath string) error {
return fmt.Errorf("error creating metadata for blueprint at path: %s. Details: %w", bpPath, err)
}

// If the flag is set, update output types
if mdFlags.genOutputType {
err = updateOutputTypes(bpPath, bpMetaObj.Spec.Interfaces)
if err != nil {
return fmt.Errorf("error updating output types: %w", err)
}
}

// write core metadata to disk
err = WriteMetadata(bpMetaObj, bpPath, metadataFileName)
if err != nil {
Expand Down Expand Up @@ -243,8 +253,8 @@ func CreateBlueprintMetadata(bpPath string, bpMetadataObj *BlueprintMetadata) (*
return nil, fmt.Errorf("error creating blueprint interfaces: %w", err)
}

// Merge existing connections (if any) into the newly generated interfaces
mergeExistingConnections(bpMetadataObj.Spec.Interfaces, existingInterfaces)
// Merge existing connections (if any) into the newly generated interfaces
mergeExistingConnections(bpMetadataObj.Spec.Interfaces, existingInterfaces)

// get blueprint requirements
rolesCfgPath := path.Join(repoDetails.Source.BlueprintRootPath, tfRolesFileName)
Expand Down
64 changes: 64 additions & 0 deletions cli/bpmetadata/parser/state_parser.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package parser

import (
"bytes"
"encoding/json"
"fmt"

"google.golang.org/protobuf/types/known/structpb"

tfjson "github.com/hashicorp/terraform-json"
"github.com/zclconf/go-cty/cty"
)

func ParseOutputTypesFromState(stateData []byte) (map[string]*structpb.Value, error) {

var state tfjson.State

// Unmarshal the state data into tfjson.State
err := json.Unmarshal(stateData, &state)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal state data: %w", err)
}

outputTypeMap := make(map[string]*structpb.Value)
for name, output := range state.Values.Outputs {
pbValue, err := convertOutputTypeToStructpb(output)
if err != nil {
return nil, fmt.Errorf("failed to convert output %q to structpb.Value: %w", name, err)
}
outputTypeMap[name] = pbValue
}

return outputTypeMap, nil
}

func convertOutputTypeToStructpb(output *tfjson.StateOutput) (*structpb.Value, error) {
// Handle nil values explicitly
if output.Value == nil {
return structpb.NewNullValue(), nil
}

// Handle cases where output.Type is NilType
if output.Type == cty.NilType {
return structpb.NewNullValue(), nil
}

// Marshal the output value to JSON
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
err := enc.Encode(output.Type)
if err != nil {
return nil, fmt.Errorf("failed to marshal output type to JSON: %w", err)
}

// Unmarshal the JSON into a structpb.Value
pbValue := &structpb.Value{}
err = pbValue.UnmarshalJSON(buf.Bytes())
if err != nil {
return nil, fmt.Errorf("failed to unmarshal JSON into structpb.Value: %w", err)
}

return pbValue, nil
}
177 changes: 177 additions & 0 deletions cli/bpmetadata/parser/state_parser_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
package parser

import (
"testing"

"github.com/google/go-cmp/cmp"
"google.golang.org/protobuf/types/known/structpb"
)

func TestParseOutputTypesFromState_WithSimpleTypes(t *testing.T) {
t.Parallel()
stateData := []byte(`
{
"format_version": "1.0",
"terraform_version": "1.2.0",
"values": {
"outputs": {
"boolean_output": {
"type": "bool",
"value": true
},
"number_output": {
"type": "number",
"value": 42
},
"string_output": {
"type": "string",
"value": "foo"
}
}
}
}
`)
want := map[string]*structpb.Value{
"boolean_output": structpb.NewStringValue("bool"),
"number_output": structpb.NewStringValue("number"),
"string_output": structpb.NewStringValue("string"),
}
got, err := ParseOutputTypesFromState(stateData)
if err != nil {
t.Errorf("ParseOutputTypesFromState() error = %v", err)
return
}
if diff := cmp.Diff(got, want, cmp.Comparer(compareStructpbValues)); diff != "" {
t.Errorf("ParseOutputTypesFromState() mismatch (-got +want):\n%s", diff)
}
}

func TestParseOutputTypesFromState_WithComplexTypes(t *testing.T) {
t.Parallel()
stateData := []byte(`
{
"format_version": "1.0",
"terraform_version": "1.2.0",
"values": {
"outputs": {
"interpolated_deep": {
"type": [
"object",
{
"foo": "string",
"map": [
"object",
{
"bar": "string",
"id": "string"
}
],
"number": "number"
}
],
"value": {
"foo": "bar",
"map": {
"bar": "baz",
"id": "424881806176056736"
},
"number": 42
}
},
"list_output": {
"type": [
"tuple",
[
"string",
"string"
]
],
"value": [
"foo",
"bar"
]
},
"map_output": {
"type": [
"object",
{
"foo": "string",
"number": "number"
}
],
"value": {
"foo": "bar",
"number": 42
}
}
}
}
}
`)
want := map[string]*structpb.Value{
"interpolated_deep": structpb.NewListValue(&structpb.ListValue{Values: []*structpb.Value{
structpb.NewStringValue("object"),
structpb.NewStructValue(&structpb.Struct{Fields: map[string]*structpb.Value{
"foo": structpb.NewStringValue("string"),
"map": structpb.NewListValue(&structpb.ListValue{Values: []*structpb.Value{structpb.NewStringValue("object"), structpb.NewStructValue(&structpb.Struct{Fields: map[string]*structpb.Value{"bar": structpb.NewStringValue("string"), "id": structpb.NewStringValue("string")}})}}),
"number": structpb.NewStringValue("number"),
}}),
}}),
"list_output": structpb.NewListValue(&structpb.ListValue{Values: []*structpb.Value{
structpb.NewStringValue("tuple"),
structpb.NewListValue(&structpb.ListValue{Values: []*structpb.Value{structpb.NewStringValue("string"), structpb.NewStringValue("string")}}),
}}),
"map_output": structpb.NewListValue(&structpb.ListValue{Values: []*structpb.Value{
structpb.NewStringValue("object"),
structpb.NewStructValue(&structpb.Struct{Fields: map[string]*structpb.Value{
"foo": structpb.NewStringValue("string"),
"number": structpb.NewStringValue("number"),
}}),
}}),
}
got, err := ParseOutputTypesFromState(stateData)
if err != nil {
t.Errorf("ParseOutputTypesFromState() error = %v", err)
return
}
if diff := cmp.Diff(got, want, cmp.Comparer(compareStructpbValues)); diff != "" {
t.Errorf("ParseOutputTypesFromState() mismatch (-got +want):\n%s", diff)
}
}

func TestParseOutputTypesFromState_WithoutTypes(t *testing.T) {
t.Parallel()
stateData := []byte(`
{
"format_version": "1.0",
"terraform_version": "1.2.0",
"values": {
"outputs": {
"no_type_output": {
"value": "some_value"
}
}
}
}
`)
want := map[string]*structpb.Value{
"no_type_output": structpb.NewNullValue(), // Expecting null value when type is missing
}

got, err := ParseOutputTypesFromState(stateData)
if err != nil {
t.Errorf("ParseOutputTypesFromState() error = %v", err)
return
}
if diff := cmp.Diff(got, want, cmp.Comparer(compareStructpbValues)); diff != "" {
t.Errorf("ParseOutputTypesFromState() mismatch (-got +want):\n%s", diff)
}
}

// compareStructpbValues is a custom comparer for structpb.Value
func compareStructpbValues(x, y *structpb.Value) bool {
// Marshal to JSON and compare the JSON strings
xJSON, _ := x.MarshalJSON()
yJSON, _ := y.MarshalJSON()
return string(xJSON) == string(yJSON)
}
63 changes: 63 additions & 0 deletions cli/bpmetadata/tfconfig.go
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
package bpmetadata

import (
"flag"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"

"github.com/GoogleCloudPlatform/cloud-foundation-toolkit/cli/bpmetadata/parser"
"github.com/GoogleCloudPlatform/cloud-foundation-toolkit/infra/blueprint-test/pkg/tft"
"github.com/gruntwork-io/terratest/modules/terraform"
hcl "github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/gohcl"
"github.com/hashicorp/hcl/v2/hclparse"
"github.com/hashicorp/terraform-config-inspect/tfconfig"
testingiface "github.com/mitchellh/go-testing-interface"
"github.com/stretchr/testify/assert"
"google.golang.org/protobuf/types/known/structpb"
)

Expand Down Expand Up @@ -79,6 +85,9 @@ var moduleSchema = &hcl.BodySchema{
},
}

// Create alias for generateTFStateFile so we can mock it in unit test.
var tfState = generateTFState

// getBlueprintVersion gets both the required core version and the
// version of the blueprint
func getBlueprintVersion(configPath string) (*blueprintVersion, error) {
Expand Down Expand Up @@ -512,3 +521,57 @@ func mergeExistingConnections(newInterfaces, existingInterfaces *BlueprintInterf
}
}
}

// UpdateOutputTypes generates the terraform.tfstate file, extracts output types from it,
// and updates the output types in the provided BlueprintInterface.
func updateOutputTypes(bpPath string, bpInterfaces *BlueprintInterface) error {
// Generate the terraform.tfstate file
stateData, err := tfState(bpPath)
if err != nil {
return fmt.Errorf("error generating terraform.tfstate file: %w", err)
}

// Parse the state file and extract output types
outputTypes, err := parser.ParseOutputTypesFromState(stateData)
if err != nil {
return fmt.Errorf("error parsing output types: %w", err)
}

// Update the output types in the BlueprintInterface
for i, output := range bpInterfaces.Outputs {
if outputType, ok := outputTypes[output.Name]; ok {
bpInterfaces.Outputs[i].Type = outputType
}
}
return nil
}

// generateTFState generates the terraform.tfstate by running terraform init and apply, and terraform show to capture the state.
func generateTFState(bpPath string) ([]byte, error) {
var stateData []byte
// Construct the path to the test/setup directory
tfDir := filepath.Join(bpPath)

// testing.T checks verbose flag to determine its mode. Add this line as a flags initializer
// so the program doesn't panic
flag.Parse()
runtimeT := testingiface.RuntimeT{}

root := tft.NewTFBlueprintTest(
&runtimeT,
tft.WithTFDir(tfDir), // Setup test at the blueprint path,
)

root.DefineVerify(func(assert *assert.Assertions) {
stateStr, err := terraform.ShowE(&runtimeT, root.GetTFOptions())
if err != nil {
assert.FailNowf("Failed to generate terraform.tfstate", "Error calling `terraform show`: %v", err)
}

stateData = []byte(stateStr)
})

root.Test() // This will run terraform init and apply, and then destroy

return stateData, nil
}
Loading

0 comments on commit 925bd48

Please sign in to comment.