diff --git a/cmd/root.go b/cmd/root.go index 5428dfc..64fec27 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -32,6 +32,7 @@ var rootCmd = &cobra.Command{ // Execute adds all child commands to the root command and sets flags appropriately. // This is called by main.main(). It only needs to happen once to the rootCmd. func Execute() { + rootCmd.AddCommand(NewCmdBicep()) rootCmd.AddCommand(NewCmdHelm()) rootCmd.AddCommand(NewCmdOpenTofu()) rootCmd.AddCommand(NewCmdValidate()) diff --git a/pkg/opentofu/testdata/opentofu/simple/schema.json b/pkg/opentofu/testdata/opentofu/simple/schema.json index 51f66b9..037a068 100644 --- a/pkg/opentofu/testdata/opentofu/simple/schema.json +++ b/pkg/opentofu/testdata/opentofu/simple/schema.json @@ -1,97 +1,202 @@ { - "required": [ - "nodescription", - "testbool", - "testemptybool", - "testlist", - "testmap", - "testnumber", - "testobject", - "testset", - "teststring" - ], - "properties": { - "teststring": { - "title": "teststring", - "type": "string", - "description": "An example string variable", - "default": "string value" - }, - "testnumber": { - "title": "testnumber", - "type": "number", - "description": "An example number variable", - "default": 20 - }, - "testbool": { - "title": "testbool", - "type": "boolean", - "description": "An example bool variable", - "default": false - }, - "testemptybool": { - "title": "testemptybool", - "type": "boolean", - "description": "An example empty bool variable", - "default": false - }, - "testobject": { - "title": "testobject", - "type": "object", - "properties": { - "name": { - "title": "name", - "type": "string" - }, - "address": { - "title": "address", - "type": "string" - }, - "age": { - "title": "age", - "type": "number" - } - }, - "required": [ - "name" - ], - "description": "An example object variable", - "default": { - "name": "Bob", - "address": "123 Bob St." - } - }, - "testlist": { - "title": "testlist", - "type": "array", - "description": "An example list variable", - "items": { - "type": "string" - } - }, - "testset": { - "title": "testset", - "type": "array", - "uniqueItems": true, - "description": "An example set variable", - "items": { - "type": "string" - } - }, - "testmap": { - "title": "testmap", - "type": "object", - "description": "An example map variable", - "propertyNames": { - "pattern": "^.*$" - }, - "additionalProperties": { - "type": "string" - } - }, - "nodescription": { - "title": "nodescription", - "type": "string" - } - } + "required": [ + "nodescription", + "testbool", + "testemptybool", + "testlist", + "testmap", + "testnestedobject", + "testnumber", + "testobject", + "testset", + "teststring" + ], + "properties": { + "teststring": { + "title": "teststring", + "type": "string", + "description": "An example string variable", + "default": "string value" + }, + "testnumber": { + "title": "testnumber", + "type": "number", + "description": "An example number variable", + "default": 20 + }, + "testbool": { + "title": "testbool", + "type": "boolean", + "description": "An example bool variable", + "default": false + }, + "testemptybool": { + "title": "testemptybool", + "type": "boolean", + "description": "An example empty bool variable", + "default": false + }, + "testobject": { + "title": "testobject", + "type": "object", + "properties": { + "name": { + "title": "name", + "type": "string" + }, + "address": { + "title": "address", + "type": "string" + }, + "age": { + "title": "age", + "type": "number" + } + }, + "required": [ + "name" + ], + "description": "An example object variable", + "default": { + "name": "Bob", + "address": "123 Bob St." + } + }, + "testnestedobject": { + "title": "testnestedobject", + "description": "An example nested object variable", + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "title": "name", + "type": "string" + }, + "address": { + "title": "address", + "type": "string", + "default": "123 Bob St." + }, + "age": { + "title": "age", + "type": "number", + "default": 30 + }, + "dead": { + "title": "dead", + "type": "boolean", + "default": false + }, + "phones": { + "title": "phones", + "type": "object", + "default": { + "home": "987-654-3210" + }, + "required": [ + "home" + ], + "properties": { + "home": { + "title": "home", + "type": "string" + }, + "work": { + "title": "work", + "type": "string", + "default": "123-456-7891" + } + } + }, + "children": { + "title": "children", + "type": "array", + "default": [ + { + "name": "bob", + "occupation": { + "company": "none", + "experience": 2, + "manager": true + } + } + ], + "items": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "title": "name", + "type": "string" + }, + "occupation": { + "title": "occupation", + "type": "object", + "default": { + "company": "Massdriver", + "experience": 1, + "manager": false + }, + "required": [ + "company" + ], + "properties": { + "company": { + "title": "company", + "type": "string" + }, + "experience": { + "title": "experience", + "type": "number", + "default": 0 + }, + "manager": { + "title": "manager", + "type": "boolean", + "default": false + } + } + } + } + } + } + } + }, + "testlist": { + "title": "testlist", + "type": "array", + "description": "An example list variable", + "items": { + "type": "string" + } + }, + "testset": { + "title": "testset", + "type": "array", + "uniqueItems": true, + "description": "An example set variable", + "items": { + "type": "string" + } + }, + "testmap": { + "title": "testmap", + "type": "object", + "description": "An example map variable", + "propertyNames": { + "pattern": "^.*$" + }, + "additionalProperties": { + "type": "string" + } + }, + "nodescription": { + "title": "nodescription", + "type": "string" + } + } } diff --git a/pkg/opentofu/testdata/opentofu/simple/variables.tf b/pkg/opentofu/testdata/opentofu/simple/variables.tf index d40b2b3..182f718 100644 --- a/pkg/opentofu/testdata/opentofu/simple/variables.tf +++ b/pkg/opentofu/testdata/opentofu/simple/variables.tf @@ -1,24 +1,24 @@ variable "teststring" { - type = string - description = "An example string variable" - default = "string value" + type = string + description = "An example string variable" + default = "string value" } variable "testnumber" { - type = number - description = "An example number variable" - default = 20 + type = number + description = "An example number variable" + default = 20 } variable "testbool" { - type = bool - description = "An example bool variable" - default = false + type = bool + description = "An example bool variable" + default = false } variable "testemptybool" { - type = bool - description = "An example empty bool variable" + type = bool + description = "An example empty bool variable" } variable "testobject" { @@ -29,24 +29,60 @@ variable "testobject" { }) description = "An example object variable" default = { - name = "Bob" + name = "Bob" address = "123 Bob St." } sensitive = true } +variable "testnestedobject" { + type = object({ + name = string + address = optional(string, "123 Bob St.") + age = optional(number, 30) + dead = optional(bool, false) + phones = optional(object({ + home = string + work = optional(string, "123-456-7891") + }), { + home = "987-654-3210" + }) + children = optional(list(object({ + name = string + occupation = optional(object({ + company = string + experience = optional(number, 0), + manager = optional(bool, false) + }), { + company = "Massdriver" + experience = 1 + manager = false + }) + })), [{ + name = "bob" + occupation = { + company = "none", + experience = 2, + manager = true + } + }] + ) + }) + description = "An example nested object variable" +} + variable "testlist" { - type = list(string) + type = list(string) description = "An example list variable" } variable "testset" { - type = set(string) + type = set(string) description = "An example set variable" } variable "testmap" { - type = map(string) + type = map(string) description = "An example map variable" } diff --git a/pkg/opentofu/tofutoschema.go b/pkg/opentofu/tofutoschema.go index e697d5c..e27cfe7 100644 --- a/pkg/opentofu/tofutoschema.go +++ b/pkg/opentofu/tofutoschema.go @@ -1,7 +1,9 @@ package opentofu import ( + "encoding/json" "errors" + "fmt" "slices" "github.com/hashicorp/hcl/v2" @@ -11,6 +13,7 @@ import ( "github.com/massdriver-cloud/terraform-config-inspect/tfconfig" orderedmap "github.com/wk8/go-ordered-map/v2" "github.com/zclconf/go-cty/cty" + ctyjson "github.com/zclconf/go-cty/cty/json" ) func TofuToSchema(modulePath string) (*schema.Schema, error) { @@ -38,11 +41,20 @@ func TofuToSchema(modulePath string) (*schema.Schema, error) { func variableToSchema(variable *tfconfig.Variable) (*schema.Schema, error) { schema := new(schema.Schema) - variableType, err := variableTypeStringToCtyType(variable.Type) + variableType, defaults, err := variableTypeStringToCtyType(variable.Type) if err != nil { return nil, err } - err = hydrateSchemaFromNameAndType(variable.Name, variableType, schema) + // To simplify the logic of recursively walking the Defaults structure in objects types, + // we make the extracted Defaults a Child of a dummy "top level" node + var topLevelDefault *typeexpr.Defaults + if defaults != nil { + topLevelDefault = new(typeexpr.Defaults) + topLevelDefault.Children = map[string]*typeexpr.Defaults{ + variable.Name: defaults, + } + } + err = hydrateSchemaFromNameTypeAndDefaults(schema, variable.Name, variableType, topLevelDefault) if err != nil { return nil, err } @@ -60,52 +72,58 @@ func variableToSchema(variable *tfconfig.Variable) (*schema.Schema, error) { return schema, nil } -func variableTypeStringToCtyType(variableType string) (cty.Type, error) { +func variableTypeStringToCtyType(variableType string) (cty.Type, *typeexpr.Defaults, error) { expr, diags := hclsyntax.ParseExpression([]byte(variableType), "", hcl.Pos{Line: 1, Column: 1}) if len(diags) != 0 { - return cty.NilType, errors.New(diags.Error()) + return cty.NilType, nil, errors.New(diags.Error()) } - ty, diags := typeexpr.TypeConstraint(expr) + ty, defaults, diags := typeexpr.TypeConstraintWithDefaults(expr) if len(diags) != 0 { - return cty.NilType, errors.New(diags.Error()) + return cty.NilType, nil, errors.New(diags.Error()) } - return ty, nil + return ty, defaults, nil } -func hydrateSchemaFromNameAndType(name string, ty cty.Type, schema *schema.Schema) error { +func hydrateSchemaFromNameTypeAndDefaults(sch *schema.Schema, name string, ty cty.Type, defaults *typeexpr.Defaults) error { + sch.Title = name + + if defaults != nil { + if defVal, exists := defaults.DefaultValues[name]; exists { + sch.Default = ctyValueToInterface(defVal) + } + } + if ty.IsPrimitiveType() { - hydratePrimitiveSchema(name, ty, schema) + hydratePrimitiveSchema(sch, ty) } else if ty.IsMapType() { - hydrateMapSchema(name, ty, schema) + hydrateMapSchema(sch, name, ty, defaults) } else if ty.IsObjectType() { - hydrateObjectSchema(name, ty, schema) + hydrateObjectSchema(sch, name, ty, defaults) } else if ty.IsListType() { - hydrateArraySchema(name, ty, schema) + hydrateArraySchema(sch, name, ty, defaults) } else if ty.IsSetType() { - hydrateSetSchema(name, ty, schema) + hydrateSetSchema(sch, name, ty, defaults) } return nil } -func hydratePrimitiveSchema(name string, ty cty.Type, schema *schema.Schema) { - schema.Title = name +func hydratePrimitiveSchema(sch *schema.Schema, ty cty.Type) { switch ty { case cty.String: - schema.Type = "string" + sch.Type = "string" case cty.Bool: - schema.Type = "boolean" + sch.Type = "boolean" case cty.Number: - schema.Type = "number" + sch.Type = "number" } } -func hydrateObjectSchema(name string, ty cty.Type, sch *schema.Schema) { - sch.Title = name +func hydrateObjectSchema(sch *schema.Schema, name string, ty cty.Type, defaults *typeexpr.Defaults) { sch.Type = "object" sch.Properties = orderedmap.New[string, *schema.Schema]() for attName, attType := range ty.AttributeTypes() { attributeSchema := new(schema.Schema) - hydrateSchemaFromNameAndType(attName, attType, attributeSchema) + hydrateSchemaFromNameTypeAndDefaults(attributeSchema, attName, attType, getDefaultChildren(name, defaults)) sch.Properties.Set(attName, attributeSchema) if !ty.AttributeOptional(attName) { sch.Required = append(sch.Required, attName) @@ -114,25 +132,69 @@ func hydrateObjectSchema(name string, ty cty.Type, sch *schema.Schema) { slices.Sort(sch.Required) } -func hydrateMapSchema(name string, ty cty.Type, sch *schema.Schema) { - sch.Title = name +func hydrateMapSchema(sch *schema.Schema, name string, ty cty.Type, defaults *typeexpr.Defaults) { sch.Type = "object" sch.PropertyNames = &schema.Schema{ Pattern: "^.*$", } sch.AdditionalProperties = new(schema.Schema) - hydrateSchemaFromNameAndType("", ty.ElementType(), sch.AdditionalProperties.(*schema.Schema)) + hydrateSchemaFromNameTypeAndDefaults(sch.AdditionalProperties.(*schema.Schema), "", ty.ElementType(), getDefaultChildren(name, defaults)) } -func hydrateArraySchema(name string, ty cty.Type, sch *schema.Schema) { - sch.Title = name +func hydrateArraySchema(sch *schema.Schema, name string, ty cty.Type, defaults *typeexpr.Defaults) { sch.Type = "array" sch.Items = new(schema.Schema) - hydrateSchemaFromNameAndType("", ty.ElementType(), sch.Items) + hydrateSchemaFromNameTypeAndDefaults(sch.Items, "", ty.ElementType(), getDefaultChildren(name, defaults)) } -func hydrateSetSchema(name string, ty cty.Type, sch *schema.Schema) { - hydrateArraySchema(name, ty, sch) +func hydrateSetSchema(sch *schema.Schema, name string, ty cty.Type, defaults *typeexpr.Defaults) { + hydrateArraySchema(sch, name, ty, defaults) sch.UniqueItems = true - hydrateSchemaFromNameAndType("", ty.ElementType(), sch.Items) + hydrateSchemaFromNameTypeAndDefaults(sch.Items, "", ty.ElementType(), getDefaultChildren(name, defaults)) +} + +func ctyValueToInterface(val cty.Value) interface{} { + valJSON, err := ctyjson.Marshal(val, val.Type()) + if err != nil { + // Should never happen, since all possible known + // values have a JSON mapping. + panic(fmt.Errorf("failed to serialize default value as JSON: %s", err)) + } + var def interface{} + err = json.Unmarshal(valJSON, &def) + if err != nil { + // Again should never happen, because valJSON is + // guaranteed valid by ctyjson.Marshal. + panic(fmt.Errorf("failed to re-parse default value from JSON: %s", err)) + } + removeNullKeys(def) + return def +} + +func getDefaultChildren(name string, defaults *typeexpr.Defaults) *typeexpr.Defaults { + var children *typeexpr.Defaults + if defaults != nil { + if attDefaultVal, exists := defaults.Children[name]; exists { + children = attDefaultVal + } + } + return children +} + +// if fields are missing from the default value for an object in the HCL, they are set to null +// we want to remove these fields from the default instead of creating a null default in the schema +func removeNullKeys(defVal interface{}) { + assertedDefVal, ok := defVal.(map[string]interface{}) + if !ok { + return + } + for key, value := range assertedDefVal { + if value == nil { + delete(assertedDefVal, key) + continue + } + if valObj, ok := assertedDefVal[key].(map[string]interface{}); ok { + removeNullKeys(valObj) + } + } }