From 4c2dc11c6f1d3161231d51007832496e920b6c76 Mon Sep 17 00:00:00 2001 From: AislingHPE Date: Thu, 22 Aug 2024 12:58:08 +0100 Subject: [PATCH] Implement half of the CLI - Adds and implements --require-all and --disallow-additional-properties - Adds checks to make sure that the input folder specified is actually a folder - Generally tidies up some comments and minor parts of the code, such as logging. - Refactors cmd.go into multiple functions --- .github/workflows/CI.yml | 1 - README | 2 +- cmd/cmd.go | 94 ++++++++++++++++++++++-------- pkg/jsonschema/json-schema.go | 47 ++++++++++----- pkg/jsonschema/json-schema_test.go | 10 +++- pkg/jsonschema/type.go | 47 +++++++-------- pkg/jsonschema/validation_util.go | 2 +- pkg/jsonschema/value.go | 7 ++- pkg/jsonschema/value_test.go | 4 +- pkg/reader/reader.go | 7 ++- pkg/reader/reader_test.go | 4 +- pkg/reader/type-constraint_test.go | 6 +- test/modules/empty/.gitkeep | 0 13 files changed, 153 insertions(+), 78 deletions(-) create mode 100644 test/modules/empty/.gitkeep diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 885fa30..b4bf937 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -18,7 +18,6 @@ jobs: - name: GolangCI Lint uses: golangci/golangci-lint-action@v6 with: - # TODO: Update this version. version: v1.60.2 args: --out-format=colored-line-number test: diff --git a/README b/README index 96b1211..62088bf 100644 --- a/README +++ b/README @@ -5,4 +5,4 @@ - Consider making a github action for this - Add a make rule to make a binary - Goreleaser? -- Check github settings eg. branch protection +- slog and slog configuration (eg debug logging for condition stuff) diff --git a/cmd/cmd.go b/cmd/cmd.go index 5dc0c39..e1099ba 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -24,34 +24,26 @@ var ( disallowAdditionalProperties bool overwrite bool allowEmpty bool + requireAll bool outputStdOut bool output string input string + + errReturned error ) // rootCmd is the base command for terraschema var rootCmd = &cobra.Command{ - Use: "terraschema", - Short: "Generate JSON schema from HCL Variable Blocks in a Terraform/OpenTofu module", - Long: `TODO: Long description`, - Run: func(cmd *cobra.Command, args []string) { - path, err := filepath.Abs(input) // absolute path - if err != nil { - fmt.Printf("could not get absolute path: %v\n", err) - os.Exit(1) - } - output, err := jsonschema.CreateSchema(path, false) - if err != nil { - fmt.Printf("error creating schema: %v\n", err) - os.Exit(1) - } - jsonOutput, err := json.MarshalIndent(output, "", " ") - if err != nil { - fmt.Printf("error marshalling schema: %v\n", err) + Use: "terraschema", + Example: "terraschema -i /path/to/module -o /path/to/schema.json", + Short: "Generate JSON schema from HCL Variable Blocks in a Terraform/OpenTofu module", + Long: `TODO: Long description`, + Run: runCommand, + PostRun: func(cmd *cobra.Command, args []string) { + if errReturned != nil { + fmt.Printf("error: %v\n", errReturned) os.Exit(1) } - - fmt.Println(string(jsonOutput)) }, } @@ -66,13 +58,67 @@ func init() { rootCmd.Flags().BoolVar(&outputStdOut, "stdout", false, "output schema content to stdout instead of a file and disable error output", ) - // TODO: implement + rootCmd.Flags().BoolVar(&disallowAdditionalProperties, "disallow-additional-properties", false, "set additionalProperties to false in the generated schema and in nested objects", ) + + rootCmd.Flags().BoolVar(&allowEmpty, "allow-empty", false, + "allow empty schema if no variables are found, otherwise error", + ) + + rootCmd.Flags().BoolVar(&requireAll, "require-all", false, + "require all variables to be present in the schema, even if a default value is specified", + ) + + rootCmd.Flags().StringVarP(&input, "input", "i", ".", + "input folder containing .tf files", + ) + // TODO: implement - rootCmd.Flags().BoolVar(&allowEmpty, "allow-empty", false, "allow empty schema if no variables are found, otherwise error") - rootCmd.Flags().StringVarP(&input, "input", "i", ".", "input folder containing .tf files") - // TODO: implement - rootCmd.Flags().StringVarP(&output, "output", "o", "schema.json", "output file path for schema") + rootCmd.Flags().StringVarP(&output, "output", "o", "schema.json", + "output file path for schema", + ) +} + +func runCommand(cmd *cobra.Command, args []string) { + path, err := filepath.Abs(input) // absolute path + if err != nil { + errReturned = fmt.Errorf("could not get absolute path for %q: %w", input, err) + + return + } + + folder, err := os.Stat(path) + if err != nil { + errReturned = fmt.Errorf("could not open %q: %w", path, err) + + return + } + + if !folder.IsDir() { + errReturned = fmt.Errorf("input %q is not a directory", path) + + return + } + + output, err := jsonschema.CreateSchema(path, jsonschema.CreateSchemaOptions{ + RequireAll: requireAll, + AllowAdditionalProperties: !disallowAdditionalProperties, + AllowEmpty: allowEmpty, + }) + if err != nil { + errReturned = fmt.Errorf("error creating schema: %w", err) + + return + } + + jsonOutput, err := json.MarshalIndent(output, "", " ") + if err != nil { + errReturned = fmt.Errorf("error marshalling schema: %w", err) + + return + } + + fmt.Println(string(jsonOutput)) } diff --git a/pkg/jsonschema/json-schema.go b/pkg/jsonschema/json-schema.go index 03b496d..69fd9c9 100644 --- a/pkg/jsonschema/json-schema.go +++ b/pkg/jsonschema/json-schema.go @@ -10,23 +10,39 @@ import ( "github.com/HewlettPackard/terraschema/pkg/reader" ) -func CreateSchema(path string, strict bool) (map[string]any, error) { +type CreateSchemaOptions struct { + RequireAll bool + AllowAdditionalProperties bool + AllowEmpty bool +} + +func CreateSchema(path string, options CreateSchemaOptions) (map[string]any, error) { schemaOut := make(map[string]any) varMap, err := reader.GetVarMap(path) - // GetVarMaps returns an error if no .tf files are found in the directory. We - // ignore this error for now. - if err != nil && !errors.Is(err, reader.ErrFilesNotFound) { - return schemaOut, fmt.Errorf("error reading tf files at %s: %w", path, err) + if err != nil { + if errors.Is(err, reader.ErrFilesNotFound) { + if options.AllowEmpty { + fmt.Printf("No tf files were found in %q, creating empty schema\n", path) + + return schemaOut, nil + } + } else { + return schemaOut, fmt.Errorf("error reading tf files at %q: %w", path, err) + } } if len(varMap) == 0 { - return schemaOut, nil + if options.AllowEmpty { + return schemaOut, nil + } else { + return schemaOut, errors.New("no variables found in tf files") + } } schemaOut["$schema"] = "http://json-schema.org/draft-07/schema#" - if strict { + if !options.AllowAdditionalProperties { schemaOut["additionalProperties"] = false } else { schemaOut["additionalProperties"] = true @@ -35,12 +51,15 @@ func CreateSchema(path string, strict bool) (map[string]any, error) { properties := make(map[string]any) requiredArray := []any{} for name, variable := range varMap { - if variable.Required { + if variable.Required && !options.RequireAll { + requiredArray = append(requiredArray, name) + } + if options.RequireAll { requiredArray = append(requiredArray, name) } - node, err := createNode(name, variable, strict) + node, err := createNode(name, variable, options) if err != nil { - return schemaOut, fmt.Errorf("error creating node for %s: %w", name, err) + return schemaOut, fmt.Errorf("error creating node for %q: %w", name, err) } properties[name] = node @@ -54,16 +73,16 @@ func CreateSchema(path string, strict bool) (map[string]any, error) { return schemaOut, nil } -func createNode(name string, v model.TranslatedVariable, strict bool) (map[string]any, error) { +func createNode(name string, v model.TranslatedVariable, options CreateSchemaOptions) (map[string]any, error) { tc, err := reader.GetTypeConstraint(v.Variable.Type) if err != nil { - return nil, fmt.Errorf("getting type constraint for %s: %w", name, err) + return nil, fmt.Errorf("getting type constraint for %q: %w", name, err) } nullableIsTrue := v.Variable.Nullable != nil && *v.Variable.Nullable - node, err := getNodeFromType(name, tc, nullableIsTrue, strict) + node, err := getNodeFromType(name, tc, nullableIsTrue, options) if err != nil { - return nil, fmt.Errorf("%s: %w", name, err) + return nil, fmt.Errorf("%q: %w", name, err) } if v.Variable.Default != nil { diff --git a/pkg/jsonschema/json-schema_test.go b/pkg/jsonschema/json-schema_test.go index f12c605..0684777 100644 --- a/pkg/jsonschema/json-schema_test.go +++ b/pkg/jsonschema/json-schema_test.go @@ -32,10 +32,14 @@ func TestCreateSchema(t *testing.T) { expected, err := os.ReadFile(filepath.Join(schemaPath, name, "schema.json")) require.NoError(t, err) - result, err := CreateSchema(filepath.Join(tfPath, name), false) + result, err := CreateSchema(filepath.Join(tfPath, name), CreateSchemaOptions{ + RequireAll: false, + AllowAdditionalProperties: true, + AllowEmpty: true, + }) require.NoError(t, err) - var expectedMap map[string]interface{} + var expectedMap map[string]any err = json.Unmarshal(expected, &expectedMap) require.NoError(t, err) @@ -283,7 +287,7 @@ func TestSampleInput(t *testing.T) { input, err := os.ReadFile(tc.filePath) require.NoError(t, err) - var m interface{} + var m any err = json.Unmarshal(input, &m) require.NoError(t, err) diff --git a/pkg/jsonschema/type.go b/pkg/jsonschema/type.go index d427c7e..b5195e1 100644 --- a/pkg/jsonschema/type.go +++ b/pkg/jsonschema/type.go @@ -12,9 +12,9 @@ var simpleTypeMap = map[string]string{ "bool": "boolean", } -func getNodeFromType(name string, typeInterface any, nullable bool, strict bool) (map[string]any, error) { +func getNodeFromType(name string, typeInterface any, nullable bool, options CreateSchemaOptions) (map[string]any, error) { if nullable { - return getNullableNode(name, typeInterface, strict) + return getNullableNode(name, typeInterface, options) } switch t := typeInterface.(type) { @@ -27,18 +27,18 @@ func getNodeFromType(name string, typeInterface any, nullable bool, strict bool) return nil, fmt.Errorf("unsupported type %q", t) } case []any: - return getNodeFromSlice(t, strict) + return getNodeFromSlice(t, options) default: return nil, fmt.Errorf("unsupported type for %#v", typeInterface) } } -func getNullableNode(name string, typeInterface any, strict bool) (map[string]any, error) { +func getNullableNode(name string, typeInterface any, options CreateSchemaOptions) (map[string]any, error) { node := make(map[string]any) if typeInterface == nil { return node, nil } - internalNode, err := getNodeFromType(name, typeInterface, false, strict) + internalNode, err := getNodeFromType(name, typeInterface, false, options) if err != nil { return nil, err } @@ -58,33 +58,33 @@ func getNullableNode(name string, typeInterface any, strict bool) (map[string]an return node, nil } -func getNodeFromSlice(in []any, strict bool) (map[string]any, error) { +func getNodeFromSlice(in []any, options CreateSchemaOptions) (map[string]any, error) { switch in[0] { // "object" affects additionalProperties, properties, type and required case "object": - return getObject(in, strict) + return getObject(in, options) // "map" affects additionalProperties and type. case "map": - return getMap(in, strict) + return getMap(in, options) // "list" affects items, type case "list": - return getList(in, strict) + return getList(in, options) // "set" affects items, type, uniqueItems case "set": - return getSet(in, strict) + return getSet(in, options) // "tuple" affects items, type, maxItems, minItems case "tuple": - return getTuple(in, strict) + return getTuple(in, options) default: panic("unknown type") } } -func getObject(in []any, strict bool) (map[string]any, error) { +func getObject(in []any, options CreateSchemaOptions) (map[string]any, error) { node := map[string]any{ "type": "object", } - if strict { + if !options.AllowAdditionalProperties { node["additionalProperties"] = false } else { node["additionalProperties"] = true @@ -123,12 +123,13 @@ func getObject(in []any, strict bool) (map[string]any, error) { properties := make(map[string]any) for key, val := range inMap { - newNode, err := getNodeFromType("", val, false, strict) + newNode, err := getNodeFromType("", val, false, options) if err != nil { return nil, fmt.Errorf("object property %q: %w", key, err) } properties[key] = newNode - if !optionals[key] { + // if the variable of the sub-object is marked as optional but RequireAll is true, then it is required. + if !optionals[key] || options.RequireAll { required = append(required, key) } } @@ -141,14 +142,14 @@ func getObject(in []any, strict bool) (map[string]any, error) { return node, nil } -func getMap(in []any, strict bool) (map[string]any, error) { +func getMap(in []any, options CreateSchemaOptions) (map[string]any, error) { node := map[string]any{ "type": "object", } if len(in) != 2 { return nil, fmt.Errorf("map type must have exactly one additional element, %v", in) } - newNode, err := getNodeFromType("", in[1], false, strict) + newNode, err := getNodeFromType("", in[1], false, options) if err != nil { return nil, fmt.Errorf("map: %w", err) } @@ -157,7 +158,7 @@ func getMap(in []any, strict bool) (map[string]any, error) { return node, nil } -func getList(in []any, strict bool) (map[string]any, error) { +func getList(in []any, options CreateSchemaOptions) (map[string]any, error) { node := map[string]any{ "type": "array", } @@ -165,7 +166,7 @@ func getList(in []any, strict bool) (map[string]any, error) { return nil, fmt.Errorf("list type must have exactly one additional element, %v", in) } - newNode, err := getNodeFromType("", in[1], false, strict) + newNode, err := getNodeFromType("", in[1], false, options) if err != nil { return nil, fmt.Errorf("list: %w", err) } @@ -174,7 +175,7 @@ func getList(in []any, strict bool) (map[string]any, error) { return node, nil } -func getSet(in []any, strict bool) (map[string]any, error) { +func getSet(in []any, options CreateSchemaOptions) (map[string]any, error) { node := map[string]any{ "type": "array", "uniqueItems": true, @@ -183,7 +184,7 @@ func getSet(in []any, strict bool) (map[string]any, error) { return nil, fmt.Errorf("set type must have exactly one additional element, %v", in) } - newNode, err := getNodeFromType("", in[1], false, strict) + newNode, err := getNodeFromType("", in[1], false, options) if err != nil { return nil, fmt.Errorf("set: %w", err) } @@ -192,7 +193,7 @@ func getSet(in []any, strict bool) (map[string]any, error) { return node, nil } -func getTuple(in []any, strict bool) (map[string]any, error) { +func getTuple(in []any, options CreateSchemaOptions) (map[string]any, error) { node := map[string]any{ "type": "array", } @@ -207,7 +208,7 @@ func getTuple(in []any, strict bool) (map[string]any, error) { } for _, val := range typeSlice { - newNode, err := getNodeFromType("", val, false, strict) + newNode, err := getNodeFromType("", val, false, options) if err != nil { return nil, fmt.Errorf("tuple: %w", err) } diff --git a/pkg/jsonschema/validation_util.go b/pkg/jsonschema/validation_util.go index 95e5184..575acdf 100644 --- a/pkg/jsonschema/validation_util.go +++ b/pkg/jsonschema/validation_util.go @@ -166,7 +166,7 @@ func performOp(op *hclsyntax.Operation, node *map[string]any, num float64, nodeT } info, ok := fieldMap[operationWithTypeName{op, nodeType}] if !ok { - return fmt.Errorf("operation not supported for type %s op %v", nodeType, op) + return fmt.Errorf("operation not supported for type %q op %v", nodeType, op) } if info.minField != "" { if info.exclusive { diff --git a/pkg/jsonschema/value.go b/pkg/jsonschema/value.go index 8d571c0..201fc37 100644 --- a/pkg/jsonschema/value.go +++ b/pkg/jsonschema/value.go @@ -8,7 +8,7 @@ import ( ctyjson "github.com/zclconf/go-cty/cty/json" ) -// expressionToJSONObject converts an HCL expression to an interface{} that can be marshaled to JSON. +// expressionToJSONObject converts an HCL expression to an `any` type so that can be marshaled to JSON later. func expressionToJSONObject(in hcl.Expression) (any, error) { v, d := in.Value(&hcl.EvalContext{}) if d.HasErrors() { @@ -17,8 +17,9 @@ func expressionToJSONObject(in hcl.Expression) (any, error) { // convert the value to a simple JSON value, so that it can // be reliably marshaled to JSON. Then, unmarshal it to an - // interface{} so that it can be passed around the code without - // the additional type information. + // `any` type so that it can be passed around the code without + // the additional type information that was unmarshaled by the + // hcl package. simpleObject := ctyjson.SimpleJSONValue{Value: v} simpleAsJSON, err := simpleObject.MarshalJSON() if err != nil { diff --git a/pkg/jsonschema/value_test.go b/pkg/jsonschema/value_test.go index 8fdccd8..c545774 100644 --- a/pkg/jsonschema/value_test.go +++ b/pkg/jsonschema/value_test.go @@ -58,11 +58,11 @@ func TestExpressionToJSONObject_Default(t *testing.T) { for key, val := range defaults { expectedVal, ok := expectedMap[key] if !ok { - t.Errorf("Variable %s not found in expected map", key) + t.Errorf("Variable %q not found in expected map", key) } if d := cmp.Diff(expectedVal, val); d != "" { - t.Errorf("Variable %s has incorrect default (-want,+got):\n%s", key, d) + t.Errorf("Variable %q has incorrect default (-want,+got):\n%s", key, d) } } }) diff --git a/pkg/reader/reader.go b/pkg/reader/reader.go index e5ca428..be7adfc 100644 --- a/pkg/reader/reader.go +++ b/pkg/reader/reader.go @@ -23,6 +23,11 @@ var fileSchema = &hcl.BodySchema{ var ErrFilesNotFound = fmt.Errorf("no .tf files found in directory") +// GetVarMap reads all .tf files in a directory and returns a map of variable names to their translated values. +// For the purpose of this application, all that matters is the model.VariableBlock contained in this, which +// contains a direct unmarshal of the block itself using the hcl package. The rest of the information is for +// debugging purposes, and to simplify the process of deciding if a variable is 'required' later. Note: in 'strict' +// mode, all variables are required, regardless of whether they have a default value or not. func GetVarMap(path string) (map[string]model.TranslatedVariable, error) { // read all tf files in directory files, err := filepath.Glob(filepath.Join(path, "*.tf")) @@ -49,7 +54,7 @@ func GetVarMap(path string) (map[string]model.TranslatedVariable, error) { for _, block := range blocks.Blocks { name, translated, err := getTranslatedVariableFromBlock(block, file) if err != nil { - return nil, fmt.Errorf("error getting parsing %s: %w", name, err) + return nil, fmt.Errorf("error getting parsing %q: %w", name, err) } varMap[name] = translated } diff --git a/pkg/reader/reader_test.go b/pkg/reader/reader_test.go index 7ab0c2f..c523c7c 100644 --- a/pkg/reader/reader_test.go +++ b/pkg/reader/reader_test.go @@ -28,10 +28,10 @@ func TestGetVarMap_Required(t *testing.T) { for k, v := range varMap { if v.Required && v.Variable.Default != nil { - t.Errorf("Variable %s is required but has a default", k) + t.Errorf("Variable %q is required but has a default", k) } if !v.Required && v.Variable.Default == nil { - t.Errorf("Variable %s is not required but has no default", k) + t.Errorf("Variable %q is not required but has no default", k) } } }) diff --git a/pkg/reader/type-constraint_test.go b/pkg/reader/type-constraint_test.go index fca5ed8..6feb810 100644 --- a/pkg/reader/type-constraint_test.go +++ b/pkg/reader/type-constraint_test.go @@ -35,7 +35,7 @@ func TestGetTypeConstraint(t *testing.T) { t.Errorf("error reading tf files: %v", err) } - var expectedMap map[string]interface{} + var expectedMap map[string]any err = json.Unmarshal(expected, &expectedMap) require.NoError(t, err) @@ -44,14 +44,14 @@ func TestGetTypeConstraint(t *testing.T) { for key, val := range varMap { expectedVal, ok := expectedMap[key] if !ok { - t.Errorf("Variable %s not found in expected map", key) + t.Errorf("Variable %q not found in expected map", key) } constraint, err := GetTypeConstraint(val.Variable.Type) require.NoError(t, err) if d := cmp.Diff(expectedVal, constraint); d != "" { - t.Errorf("Variable %s has incorrect type constraint (-want,+got):\n%s", key, d) + t.Errorf("Variable %q has incorrect type constraint (-want,+got):\n%s", key, d) } } }) diff --git a/test/modules/empty/.gitkeep b/test/modules/empty/.gitkeep new file mode 100644 index 0000000..e69de29