From 925bd48c99b1e3aed7d809f50bf6af09e33862ef Mon Sep 17 00:00:00 2001 From: Jieyu Tian Date: Tue, 1 Oct 2024 12:42:00 -0400 Subject: [PATCH] feat: Add a command to automatically generate type info for output in metadata file (#2602) --- cli/bpmetadata/cmd.go | 26 ++- cli/bpmetadata/parser/state_parser.go | 64 +++++++ cli/bpmetadata/parser/state_parser_test.go | 177 ++++++++++++++++++ cli/bpmetadata/tfconfig.go | 63 +++++++ cli/bpmetadata/tfconfig_test.go | 101 +++++++++- .../interfaces_without_types_metadata.yaml | 14 ++ .../tf/sample-module/terraform.tfstate | 25 +++ 7 files changed, 461 insertions(+), 9 deletions(-) create mode 100644 cli/bpmetadata/parser/state_parser.go create mode 100644 cli/bpmetadata/parser/state_parser_test.go create mode 100644 cli/testdata/bpmetadata/metadata/interfaces_without_types_metadata.yaml create mode 100644 cli/testdata/bpmetadata/tf/sample-module/terraform.tfstate diff --git a/cli/bpmetadata/cmd.go b/cli/bpmetadata/cmd.go index 5430a2bf3a3..a288a03f264 100644 --- a/cli/bpmetadata/cmd.go +++ b/cli/bpmetadata/cmd.go @@ -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 ( @@ -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{ @@ -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 { @@ -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) diff --git a/cli/bpmetadata/parser/state_parser.go b/cli/bpmetadata/parser/state_parser.go new file mode 100644 index 00000000000..42d4ad76565 --- /dev/null +++ b/cli/bpmetadata/parser/state_parser.go @@ -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 +} diff --git a/cli/bpmetadata/parser/state_parser_test.go b/cli/bpmetadata/parser/state_parser_test.go new file mode 100644 index 00000000000..a97417509bc --- /dev/null +++ b/cli/bpmetadata/parser/state_parser_test.go @@ -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) +} diff --git a/cli/bpmetadata/tfconfig.go b/cli/bpmetadata/tfconfig.go index d54257b41b3..819feda47cd 100644 --- a/cli/bpmetadata/tfconfig.go +++ b/cli/bpmetadata/tfconfig.go @@ -1,6 +1,7 @@ package bpmetadata import ( + "flag" "fmt" "os" "path/filepath" @@ -8,10 +9,15 @@ import ( "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" ) @@ -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) { @@ -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 +} diff --git a/cli/bpmetadata/tfconfig_test.go b/cli/bpmetadata/tfconfig_test.go index 8752762c6c0..123d86a95d3 100644 --- a/cli/bpmetadata/tfconfig_test.go +++ b/cli/bpmetadata/tfconfig_test.go @@ -1,13 +1,16 @@ package bpmetadata import ( + "fmt" + "os" "path" + "slices" "testing" "github.com/hashicorp/hcl/v2/hclparse" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "golang.org/x/exp/slices" + "google.golang.org/protobuf/types/known/structpb" ) const ( @@ -413,3 +416,99 @@ func TestTFVariableSortOrder(t *testing.T) { }) } } + +func TestUpdateOutputTypes(t *testing.T) { + tests := []struct { + name string + bpPath string + interfacesFile string + stateFile string + expectedOutputs []*BlueprintOutput + expectError bool + }{ + { + name: "Update output types from state", + bpPath: "sample-module", + interfacesFile: "interfaces_without_types_metadata.yaml", + stateFile: "terraform.tfstate", + expectedOutputs: []*BlueprintOutput{ + { + Name: "cluster_id", + Description: "Cluster ID", + Type: structpb.NewStringValue("string"), + }, + { + Name: "endpoint", + Description: "Cluster endpoint", + Type: &structpb.Value{ + Kind: &structpb.Value_ListValue{ + ListValue: &structpb.ListValue{ + Values: []*structpb.Value{ + { + Kind: &structpb.Value_StringValue{ + StringValue: "object", + }, + }, + { + Kind: &structpb.Value_StructValue{ + StructValue: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "host": { + Kind: &structpb.Value_StringValue{ + StringValue: "string", + }, + }, + "port": { + Kind: &structpb.Value_StringValue{ + StringValue: "number", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Load interfaces from file + bpInterfaces, err := UnmarshalMetadata(metadataTestdataPath, tt.interfacesFile) + require.NoError(t, err) + + // Override with a function that reads a hard-coded tfstate file. + tfState = func(_ string) ([]byte, error) { + if tt.expectError { + return nil, fmt.Errorf("simulated error generating state file") + } + // Copy the test state file to the bpPath + stateFilePath := path.Join(tfTestdataPath, tt.bpPath, tt.stateFile) + stateData, err := os.ReadFile(stateFilePath) + if err != nil { + return nil, fmt.Errorf("error reading state file: %w", err) + } + return stateData, nil + } + + // Update output types + err = updateOutputTypes(path.Join(tfTestdataPath, tt.bpPath), bpInterfaces.Spec.Interfaces) + + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + // Assert that the output types are updated correctly + expectedOutputsStr := fmt.Sprintf("%v", tt.expectedOutputs) + actualOutputsStr := fmt.Sprintf("%v", bpInterfaces.Spec.Interfaces.Outputs) + assert.Equal(t, expectedOutputsStr, actualOutputsStr) + } + }) + } +} diff --git a/cli/testdata/bpmetadata/metadata/interfaces_without_types_metadata.yaml b/cli/testdata/bpmetadata/metadata/interfaces_without_types_metadata.yaml new file mode 100644 index 00000000000..50320f98b65 --- /dev/null +++ b/cli/testdata/bpmetadata/metadata/interfaces_without_types_metadata.yaml @@ -0,0 +1,14 @@ +# interfaces_without_types_metadata.yaml +apiVersion: blueprints.cloud.google.com/v1alpha1 +kind: BlueprintMetadata +metadata: + name: sample-module + annotations: + config.kubernetes.io/local-config: "true" +spec: + interfaces: + outputs: + - name: cluster_id + description: Cluster ID + - name: endpoint + description: Cluster endpoint \ No newline at end of file diff --git a/cli/testdata/bpmetadata/tf/sample-module/terraform.tfstate b/cli/testdata/bpmetadata/tf/sample-module/terraform.tfstate new file mode 100644 index 00000000000..1d19d377e55 --- /dev/null +++ b/cli/testdata/bpmetadata/tf/sample-module/terraform.tfstate @@ -0,0 +1,25 @@ +{ + "format_version": "1.0", + "terraform_version": "1.2.0", + "values": { + "outputs": { + "cluster_id": { + "type": "string", + "value": "sample-cluster-id" + }, + "endpoint": { + "type": [ + "object", + { + "host": "string", + "port": "number" + } + ], + "value": { + "host": "127.0.0.1", + "port": 443 + } + } + } + } +} \ No newline at end of file