diff --git a/pkg/opentofu/testdata/opentofu/any/variables.tf b/pkg/opentofu/testdata/opentofu/any/variables.tf new file mode 100644 index 0000000..53521e6 --- /dev/null +++ b/pkg/opentofu/testdata/opentofu/any/variables.tf @@ -0,0 +1,3 @@ +variable "foo" { + type = any +} \ No newline at end of file diff --git a/pkg/opentofu/testdata/opentofu/empty/variables.tf b/pkg/opentofu/testdata/opentofu/empty/variables.tf new file mode 100644 index 0000000..27d4129 --- /dev/null +++ b/pkg/opentofu/testdata/opentofu/empty/variables.tf @@ -0,0 +1 @@ +variable "foo" {} \ No newline at end of file diff --git a/pkg/opentofu/testdata/opentofu/nestedany/variables.tf b/pkg/opentofu/testdata/opentofu/nestedany/variables.tf new file mode 100644 index 0000000..1f099e0 --- /dev/null +++ b/pkg/opentofu/testdata/opentofu/nestedany/variables.tf @@ -0,0 +1,5 @@ +variable "foo" { + type = object({ + bar = any + }) +} \ No newline at end of file diff --git a/pkg/opentofu/tofutoschema.go b/pkg/opentofu/tofutoschema.go index 306ac47..2ec509c 100644 --- a/pkg/opentofu/tofutoschema.go +++ b/pkg/opentofu/tofutoschema.go @@ -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) @@ -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 @@ -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 @@ -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()) @@ -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 { @@ -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) { @@ -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{} { diff --git a/pkg/opentofu/tofutoschema_test.go b/pkg/opentofu/tofutoschema_test.go index e2fe889..47ccae4 100644 --- a/pkg/opentofu/tofutoschema_test.go +++ b/pkg/opentofu/tofutoschema_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" "github.com/massdriver-cloud/airlock/pkg/opentofu" @@ -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))