Skip to content
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
3 changes: 3 additions & 0 deletions pkg/opentofu/testdata/opentofu/any/variables.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
variable "foo" {
type = any
}
1 change: 1 addition & 0 deletions pkg/opentofu/testdata/opentofu/empty/variables.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
variable "foo" {}
5 changes: 5 additions & 0 deletions pkg/opentofu/testdata/opentofu/nestedany/variables.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
variable "foo" {
type = object({
bar = any
})
}
53 changes: 34 additions & 19 deletions pkg/opentofu/tofutoschema.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ func TofuToSchema(modulePath string) (*schema.Schema, error) {
for _, variable := range module.Variables {
variableSchema, err := variableToSchema(variable)
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to convert variable %q to schema: %w", variable.Name, err)
}
sch.Properties.Set(variable.Name, variableSchema)
sch.Required = append(sch.Required, variable.Name)
Expand All @@ -41,9 +41,9 @@ func TofuToSchema(modulePath string) (*schema.Schema, error) {

func variableToSchema(variable *tfconfig.Variable) (*schema.Schema, error) {
schema := new(schema.Schema)
variableType, defaults, err := variableTypeStringToCtyType(variable.Type)
if err != nil {
return nil, err
variableType, defaults, typeErr := variableTypeStringToCtyType(variable.Type)
if typeErr != nil {
return nil, fmt.Errorf("failed to parse type %q: %w", variable.Type, typeErr)
}
// 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
Expand All @@ -54,7 +54,9 @@ func variableToSchema(variable *tfconfig.Variable) (*schema.Schema, error) {
variable.Name: defaults,
}
}
hydrateSchemaFromNameTypeAndDefaults(schema, variable.Name, variableType, topLevelDefault)
if hydrateErr := hydrateSchemaFromNameTypeAndDefaults(schema, variable.Name, variableType, topLevelDefault); hydrateErr != nil {
return nil, fmt.Errorf("failed to hydrate schema for variable %q: %w", variable.Name, hydrateErr)
}

schema.Description = variable.Description

Expand All @@ -70,6 +72,12 @@ func variableToSchema(variable *tfconfig.Variable) (*schema.Schema, error) {
}

func variableTypeStringToCtyType(variableType string) (cty.Type, *typeexpr.Defaults, error) {
if variableType == "" {
return cty.NilType, nil, errors.New("type cannot be empty")
}
if variableType == "any" {
return cty.NilType, nil, errors.New("type 'any' cannot be converted to a JSON schema type")
}
expr, diags := hclsyntax.ParseExpression([]byte(variableType), "", hcl.Pos{Line: 1, Column: 1})
if len(diags) != 0 {
return cty.NilType, nil, errors.New(diags.Error())
Expand All @@ -81,7 +89,7 @@ func variableTypeStringToCtyType(variableType string) (cty.Type, *typeexpr.Defau
return ty, defaults, nil
}

func hydrateSchemaFromNameTypeAndDefaults(sch *schema.Schema, name string, ty cty.Type, defaults *typeexpr.Defaults) {
func hydrateSchemaFromNameTypeAndDefaults(sch *schema.Schema, name string, ty cty.Type, defaults *typeexpr.Defaults) error {
sch.Title = name

if defaults != nil {
Expand All @@ -94,14 +102,19 @@ func hydrateSchemaFromNameTypeAndDefaults(sch *schema.Schema, name string, ty ct
if ty.IsPrimitiveType() {
hydratePrimitiveSchema(sch, ty)
} else if ty.IsMapType() {
hydrateMapSchema(sch, name, ty, defaults)
return hydrateMapSchema(sch, name, ty, defaults)
} else if ty.IsObjectType() {
hydrateObjectSchema(sch, name, ty, defaults)
return hydrateObjectSchema(sch, name, ty, defaults)
} else if ty.IsListType() {
hydrateArraySchema(sch, name, ty, defaults)
return hydrateArraySchema(sch, name, ty, defaults)
} else if ty.IsSetType() {
hydrateSetSchema(sch, name, ty, defaults)
return hydrateSetSchema(sch, name, ty, defaults)
} else if ty.HasDynamicTypes() {
return fmt.Errorf("dynamic types are not supported (are you using type 'any'?)")
} else {
return fmt.Errorf("unsupported type %q", ty.FriendlyName())
}
return nil
}

func hydratePrimitiveSchema(sch *schema.Schema, ty cty.Type) {
Expand All @@ -115,39 +128,41 @@ func hydratePrimitiveSchema(sch *schema.Schema, ty cty.Type) {
}
}

func hydrateObjectSchema(sch *schema.Schema, name string, ty cty.Type, defaults *typeexpr.Defaults) {
func hydrateObjectSchema(sch *schema.Schema, name string, ty cty.Type, defaults *typeexpr.Defaults) error {
sch.Type = "object"
sch.Properties = orderedmap.New[string, *schema.Schema]()
for attName, attType := range ty.AttributeTypes() {
attributeSchema := new(schema.Schema)
hydrateSchemaFromNameTypeAndDefaults(attributeSchema, attName, attType, getDefaultChildren(name, defaults))
if err := hydrateSchemaFromNameTypeAndDefaults(attributeSchema, attName, attType, getDefaultChildren(name, defaults)); err != nil {
return err
}
sch.Properties.Set(attName, attributeSchema)
if !ty.AttributeOptional(attName) {
sch.Required = append(sch.Required, attName)
}
}
slices.Sort(sch.Required)
return nil
}

func hydrateMapSchema(sch *schema.Schema, name string, ty cty.Type, defaults *typeexpr.Defaults) {
func hydrateMapSchema(sch *schema.Schema, name string, ty cty.Type, defaults *typeexpr.Defaults) error {
sch.Type = "object"
sch.PropertyNames = &schema.Schema{
Pattern: "^.*$",
}
sch.AdditionalProperties = new(schema.Schema)
hydrateSchemaFromNameTypeAndDefaults(sch.AdditionalProperties.(*schema.Schema), "", ty.ElementType(), getDefaultChildren(name, defaults))
return hydrateSchemaFromNameTypeAndDefaults(sch.AdditionalProperties.(*schema.Schema), "", ty.ElementType(), getDefaultChildren(name, defaults))
}

func hydrateArraySchema(sch *schema.Schema, name string, ty cty.Type, defaults *typeexpr.Defaults) {
func hydrateArraySchema(sch *schema.Schema, name string, ty cty.Type, defaults *typeexpr.Defaults) error {
sch.Type = "array"
sch.Items = new(schema.Schema)
hydrateSchemaFromNameTypeAndDefaults(sch.Items, "", ty.ElementType(), getDefaultChildren(name, defaults))
return hydrateSchemaFromNameTypeAndDefaults(sch.Items, "", ty.ElementType(), getDefaultChildren(name, defaults))
}

func hydrateSetSchema(sch *schema.Schema, name string, ty cty.Type, defaults *typeexpr.Defaults) {
hydrateArraySchema(sch, name, ty, defaults)
func hydrateSetSchema(sch *schema.Schema, name string, ty cty.Type, defaults *typeexpr.Defaults) error {
sch.UniqueItems = true
hydrateSchemaFromNameTypeAndDefaults(sch.Items, "", ty.ElementType(), getDefaultChildren(name, defaults))
return hydrateArraySchema(sch, name, ty, defaults)
}

func ctyValueToInterface(val cty.Value) interface{} {
Expand Down
41 changes: 32 additions & 9 deletions pkg/opentofu/tofutoschema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"

"github.com/massdriver-cloud/airlock/pkg/opentofu"
Expand All @@ -14,29 +15,51 @@ import (
func TestTofuToSchema(t *testing.T) {
type testData struct {
name string
err string
}
tests := []testData{
{
name: "simple",
},
{
name: "any",
err: "type 'any' cannot be converted to a JSON schema type",
},
{
name: "nestedany",
err: "dynamic types are not supported (are you using type 'any'?)",
},
{
name: "empty",
err: "type cannot be empty",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
modulePath := filepath.Join("testdata/opentofu", tc.name)

want, err := os.ReadFile(filepath.Join("testdata/opentofu", tc.name, "schema.json"))
if err != nil {
t.Fatalf("%d, unexpected error", err)
got, schemaErr := opentofu.TofuToSchema(modulePath)
if schemaErr != nil && tc.err == "" {
t.Fatalf("unexpected error: %s", schemaErr.Error())
}
if tc.err != "" && schemaErr == nil {
t.Fatalf("expected error %s, got nil", tc.err)
}
if tc.err != "" && !strings.Contains(schemaErr.Error(), tc.err) {
t.Fatalf("expected error %s, got %s", tc.err, schemaErr.Error())
}
if tc.err != "" {
return
}

got, err := opentofu.TofuToSchema(modulePath)
if err != nil {
t.Fatalf("%d, unexpected error", err)
bytes, marshalErr := json.Marshal(got)
if marshalErr != nil {
t.Fatalf("unexpected error: %s", marshalErr.Error())
}

bytes, err := json.Marshal(got)
if err != nil {
t.Fatalf("%d, unexpected error", err)
want, readErr := os.ReadFile(filepath.Join("testdata/opentofu", tc.name, "schema.json"))
if readErr != nil {
t.Fatalf("unexpected error: %s", readErr.Error())
}

require.JSONEq(t, string(want), string(bytes))
Expand Down
Loading