diff --git a/go.mod b/go.mod index ca68a402..bad0f047 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/google/uuid v1.6.0 github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-version v1.7.0 + github.com/hashicorp/hcl/v2 v2.22.0 github.com/jstemmer/go-junit-report/v2 v2.1.0 github.com/mitchellh/mapstructure v1.5.0 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 @@ -31,6 +32,7 @@ require ( github.com/spf13/pflag v1.0.6-0.20201009195203-85dd5c8bc61c github.com/spf13/viper v1.19.0 github.com/stretchr/testify v1.9.0 + github.com/zclconf/go-cty v1.13.0 go.mondoo.com/cnquery/v11 v11.27.0 go.mondoo.com/mondoo-go v0.0.0-20241019084804-ed418047ea3a go.mondoo.com/ranger-rpc v0.6.4 @@ -79,6 +81,8 @@ require ( github.com/alecthomas/participle v0.3.0 // indirect github.com/alecthomas/participle/v2 v2.1.1 // indirect github.com/anchore/go-struct-converter v0.0.0-20221118182256-c68fdcfa2092 // indirect + github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect + github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aws/aws-sdk-go v1.55.5 // indirect github.com/aws/aws-sdk-go-v2 v1.32.2 // indirect @@ -222,6 +226,7 @@ require ( github.com/miekg/dns v1.1.62 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-testing-interface v1.14.1 // indirect + github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/moby/buildkit v0.16.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect diff --git a/go.sum b/go.sum index 3eb18771..2b592eec 100644 --- a/go.sum +++ b/go.sum @@ -160,7 +160,10 @@ github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/apparentlymart/go-textseg/v13 v13.0.0 h1:Y+KvPE1NYz0xl601PVImeQfFyEy6iT90AvPUL1NNfNw= github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo= +github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= +github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-metrics v0.3.10/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc= @@ -601,6 +604,8 @@ github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hashicorp/hcl/v2 v2.22.0 h1:hkZ3nCtqeJsDhPRFz5EA9iwcG1hNWGePOTw6oyul12M= +github.com/hashicorp/hcl/v2 v2.22.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= github.com/hashicorp/mdns v1.0.1/go.mod h1:4gW7WsVCke5TE7EPeYliwHlRUyBtfCwuFwuMg2DmyNY= github.com/hashicorp/mdns v1.0.4/go.mod h1:mtBihi+LeNXGtG8L9dX59gAEa12BDtBQSp4v/YAJqrc= @@ -743,6 +748,8 @@ github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrk github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= @@ -972,6 +979,10 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zclconf/go-cty v1.10.0/go.mod h1:vVKLxnk3puL4qRAv72AO+W99LUD4da90g3uUAzyuvAk= +github.com/zclconf/go-cty v1.13.0 h1:It5dfKTTZHe9aeppbNOda3mN7Ag7sg6QkBNm6TkyFa0= +github.com/zclconf/go-cty v1.13.0/go.mod h1:YKQzy/7pZ7iq2jNFzy5go57xdxdWoLLpaEp4u238AE0= +github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= +github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= go.etcd.io/etcd/api/v3 v3.5.1/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= go.etcd.io/etcd/client/pkg/v3 v3.5.1/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= go.etcd.io/etcd/client/v2 v2.305.1/go.mod h1:pMEacxZW7o8pg4CrFE7pquyCJJzZvkvdD2RibOCCCGs= diff --git a/internal/tfgen/hcl.go b/internal/tfgen/hcl.go new file mode 100644 index 00000000..33992625 --- /dev/null +++ b/internal/tfgen/hcl.go @@ -0,0 +1,693 @@ +// Copyright (c) Mondoo, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +// A package that generates Terraform deployment code. +package tfgen + +import ( + "errors" + "fmt" + "sort" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/hashicorp/hcl/v2/hclwrite" + "github.com/zclconf/go-cty/cty" +) + +type HclProvider struct { + // Required. Provider name. + name string + + // Optional. Extra properties for this module. + // Can supply string, bool, int, or map[string]interface{} as values + attributes map[string]interface{} + + // Optional. Generic blocks + blocks []*hclwrite.Block +} + +func (p *HclProvider) ToBlock() (*hclwrite.Block, error) { + block, err := HclCreateGenericBlock("provider", []string{p.name}, p.attributes) + if err != nil { + return nil, err + } + + if p.blocks != nil { + for _, b := range p.blocks { + block.Body().AppendNewline() + block.Body().AppendBlock(b) + } + } + + return block, nil +} + +type HclProviderModifier func(p *HclProvider) + +// NewProvider Create a new HCL Provider +func NewProvider(name string, mods ...HclProviderModifier) *HclProvider { + provider := &HclProvider{name: name} + for _, m := range mods { + m(provider) + } + return provider +} + +func HclProviderWithAttributes(attrs map[string]interface{}) HclProviderModifier { + return func(p *HclProvider) { + p.attributes = attrs + } +} + +// HclProviderWithGenericBlocks sets the generic blocks within the provider. +func HclProviderWithGenericBlocks(blocks ...*hclwrite.Block) HclProviderModifier { + return func(p *HclProvider) { + p.blocks = blocks + } +} + +type HclRequiredProvider struct { + name string + source string + version string +} + +func (p *HclRequiredProvider) Source() string { + return p.source +} + +func (p *HclRequiredProvider) Version() string { + return p.version +} + +func (p *HclRequiredProvider) Name() string { + return p.name +} + +type HclRequiredProviderModifier func(p *HclRequiredProvider) + +func HclRequiredProviderWithSource(source string) HclRequiredProviderModifier { + return func(p *HclRequiredProvider) { + p.source = source + } +} + +func HclRequiredProviderWithVersion(version string) HclRequiredProviderModifier { + return func(p *HclRequiredProvider) { + p.version = version + } +} + +func NewRequiredProvider(name string, mods ...HclRequiredProviderModifier) *HclRequiredProvider { + provider := &HclRequiredProvider{name: name} + for _, m := range mods { + m(provider) + } + return provider +} + +type ForEach struct { + key string + value map[string]string +} + +type HclOutput struct { + // Required. Name of the resultant output. + name string + + // Required. Converted into a traversal. + // e.g. []string{"a", "b", "c"} as input results in traversal having value a.b.c + value []string + + // Optional. + description string +} + +func (m *HclOutput) ToBlock() (*hclwrite.Block, error) { + if m.value == nil { + return nil, errors.New("value must be supplied") + } + + attributes := map[string]interface{}{ + "value": CreateSimpleTraversal(m.value...), + } + + if m.description != "" { + attributes["description"] = m.description + } + + block, err := HclCreateGenericBlock( + "output", + []string{m.name}, + attributes, + ) + if err != nil { + return nil, err + } + + return block, nil +} + +// NewOutput Create a provider statement in the HCL output. +func NewOutput(name string, value []string, description string) *HclOutput { + return &HclOutput{name: name, description: description, value: value} +} + +type HclModule struct { + // Required. Module name. + name string + + // Required. Source for this module. + source string + + // Required. Version. + version string + + // Optional. Extra properties for this module. + // Can supply string, bool, int, or map[string]interface{} as values + attributes map[string]interface{} + + // Optional. Provide a map of strings. Creates an instance of the module block for each item in the map, with the + // map keys assigned to the key field. + forEach *ForEach + + // Optional. Provider details to override defaults. These values must be supplied as strings, and raw values will be + // accepted. Unfortunately map[string]hcl.Traversal is not a format that is supported by hclwrite.SetAttributeValue + // today so we must work around it (https://github.com/hashicorp/hcl/issues/347). + providerDetails map[string]string +} + +type HclModuleModifier func(p *HclModule) + +// NewModule Create a provider statement in the HCL output. +func NewModule(name string, source string, mods ...HclModuleModifier) *HclModule { + module := &HclModule{name: name, source: source} + for _, m := range mods { + m(module) + } + return module +} + +// HclModuleWithAttributes Used to set parameters within the module usage. +func HclModuleWithAttributes(attrs map[string]interface{}) HclModuleModifier { + return func(p *HclModule) { + p.attributes = attrs + } +} + +// HclModuleWithVersion Used to set the version of a module source to use. +func HclModuleWithVersion(version string) HclModuleModifier { + return func(p *HclModule) { + p.version = version + } +} + +// HclModuleWithProviderDetails Used to provide additional provider details to a given module. +// +// Note: The values supplied become traversals +// +// e.g. https://www.terraform.io/docs/language/modules/develop/providers.html#passing-providers-explicitly +func HclModuleWithProviderDetails(providerDetails map[string]string) HclModuleModifier { + return func(p *HclModule) { + p.providerDetails = providerDetails + } +} + +func HclModuleWithForEach(key string, value map[string]string) HclModuleModifier { + return func(p *HclModule) { + p.forEach = &ForEach{key, value} + } +} + +// ToBlock Create hclwrite.Block for module. +func (m *HclModule) ToBlock() (*hclwrite.Block, error) { + if m.attributes == nil { + m.attributes = make(map[string]interface{}) + } + if m.source != "" { + m.attributes["source"] = m.source + + } + if m.version != "" { + m.attributes["version"] = m.version + } + block, err := HclCreateGenericBlock( + "module", + []string{m.name}, + m.attributes, + ) + if err != nil { + return nil, err + } + + if m.forEach != nil { + block.Body().AppendNewline() + + value, err := convertTypeToCty(m.forEach.value) + if err != nil { + return nil, err + } + block.Body().SetAttributeValue("for_each", value) + + block.Body().SetAttributeRaw(m.forEach.key, createForEachKey()) + } + + if m.providerDetails != nil { + block.Body().AppendNewline() + block.Body().SetAttributeRaw("providers", CreateMapTraversalTokens(m.providerDetails)) + } + + return block, nil +} + +// ToResourceBlock Create hclwrite.Block for resource. +func (m *HclResource) ToResourceBlock() (*hclwrite.Block, error) { + if m.attributes == nil { + m.attributes = make(map[string]interface{}) + } + + block, err := HclCreateGenericBlock( + "resource", + []string{m.rType, m.name}, + m.attributes, + ) + if err != nil { + return nil, err + } + + if m.providerDetails != nil { + block.Body().AppendNewline() + block.Body().SetAttributeTraversal("provider", CreateSimpleTraversal(m.providerDetails...)) + } + + if m.blocks != nil { + for _, b := range m.blocks { + block.Body().AppendNewline() + block.Body().AppendBlock(b) + } + } + + return block, nil +} + +type HclResource struct { + // Required. Resource type. + rType string + + // Required. Resource name. + name string + + // Optional. Extra properties for this resource. + // Can supply string, bool, int, or map[string]interface{} as values + attributes map[string]interface{} + + // Optional. Provider details to override defaults. + // These values must be supplied as strings, and raw values will be accepted.Unfortunately + // map[string]hcl.Traversal is not a format that is supported by hclwrite.SetAttributeValue + // today so we must work around it (https://github.com/hashicorp/hcl/issues/347). + providerDetails []string + + // Optional. Generic blocks + blocks []*hclwrite.Block +} + +type HclResourceModifier func(p *HclResource) + +// NewResource Create a provider statement in the HCL output. +func NewResource(rType string, name string, mods ...HclResourceModifier) *HclResource { + resource := &HclResource{rType: rType, name: name} + for _, m := range mods { + m(resource) + } + return resource +} + +// HclResourceWithAttributesAndProviderDetails Used to set parameters within the resource usage. +func HclResourceWithAttributesAndProviderDetails(attrs map[string]interface{}, + providerDetails []string) HclResourceModifier { + return func(p *HclResource) { + p.attributes = attrs + p.providerDetails = providerDetails + } +} + +// HclResourceWithGenericBlocks sets the generic blocks within the resource. +func HclResourceWithGenericBlocks(blocks ...*hclwrite.Block) HclResourceModifier { + return func(p *HclResource) { + p.blocks = blocks + } +} + +// Convert standard value types to cty.Value. +// +// All values used in hclwrite.Block(s) must be cty.Value or a cty.Traversal. +// This function performs that conversion for standard types (non-traversal). +func convertTypeToCty(value interface{}) (cty.Value, error) { + switch v := value.(type) { + case string: + return cty.StringVal(v), nil + case int: + return cty.NumberIntVal(int64(v)), nil + case int64: + return cty.NumberIntVal(int64(v)), nil + case bool: + return cty.BoolVal(v), nil + case map[string]string: + if len(v) == 0 { + return cty.NilVal, nil + } + valueMap := map[string]cty.Value{} + for key, val := range v { + valueMap[key] = cty.StringVal(val) + } + return cty.MapVal(valueMap), nil + case map[string]interface{}: + if len(v) == 0 { + return cty.NilVal, nil + } + valueMap := map[string]cty.Value{} + for key, val := range v { + convertedValue, err := convertTypeToCty(val) + if err != nil { + return cty.NilVal, err + } + valueMap[key] = convertedValue + } + return cty.MapVal(valueMap), nil + case []map[string]interface{}: + values := []cty.Value{} + for _, item := range v { + valueMap := map[string]cty.Value{} + for key, val := range item { + convertedValue, err := convertTypeToCty(val) + if err != nil { + return cty.NilVal, err + } + valueMap[key] = convertedValue + } + values = append(values, cty.ObjectVal(valueMap)) + } + return cty.ListVal(values), nil + case []string: + if len(v) == 0 { + return cty.ListValEmpty(cty.String), nil + } + valueSlice := []cty.Value{} + for _, s := range v { + valueSlice = append(valueSlice, cty.StringVal(s)) + } + return cty.ListVal(valueSlice), nil + case []interface{}: + valueSlice := []cty.Value{} + for _, i := range v { + newVal, err := convertTypeToCty(i) + if err != nil { + return cty.Value{}, err + } + valueSlice = append(valueSlice, newVal) + } + return cty.TupleVal(valueSlice), nil + default: + return cty.NilVal, errors.New("unknown attribute value type") + } +} + +// Used to set block attribute values based on attribute value interface type. +// +// hclwrite.Block attributes use cty.Value, hclwrite.Tokens or can be traversals, this function +// determines what type of value is being used and builds the block accordingly. +func setBlockAttributeValue(block *hclwrite.Block, key string, val interface{}) error { + switch v := val.(type) { + case hcl.Traversal: + block.Body().SetAttributeTraversal(key, v) + case hclwrite.Tokens: + block.Body().SetAttributeRaw(key, v) + case string, int, bool, []string, []interface{}, map[string]string: + value, err := convertTypeToCty(v) + if err != nil { + return err + } + block.Body().SetAttributeValue(key, value) + case []map[string]interface{}: + values := []cty.Value{} + for _, item := range v { + valueMap := map[string]cty.Value{} + for key, val := range item { + convertedValue, err := convertTypeToCty(val) + if err != nil { + return err + } + valueMap[key] = convertedValue + } + values = append(values, cty.ObjectVal(valueMap)) + } + + if !cty.CanListVal(values) { + return errors.New( + "setBlockAttributeValue: Values can not be coalesced into a single List due to inconsistent element types", + ) + } + block.Body().SetAttributeValue(key, cty.ListVal(values)) + case map[string]interface{}: + var keys []string + for k := range v { + keys = append(keys, k) + } + sort.Strings(keys) + objects := []hclwrite.ObjectAttrTokens{} + for _, attrKey := range keys { + attrVal := v[attrKey] + t, ok := attrVal.(hclwrite.Tokens) + if !ok { + value, err := convertTypeToCty(attrVal) + if err != nil { + return err + } + t = hclwrite.TokensForValue(value) + } + objects = append(objects, hclwrite.ObjectAttrTokens{ + Name: hclwrite.TokensForIdentifier(attrKey), + Value: t, + }) + } + + block.Body().SetAttributeRaw(key, hclwrite.TokensForObject(objects)) + default: + return fmt.Errorf("setBlockAttributeValue: unknown type for key: %s", key) + } + + return nil +} + +// HclCreateGenericBlock Helper to create various types of new hclwrite.Block using generic inputs. +func HclCreateGenericBlock(hcltype string, labels []string, attr map[string]interface{}) (*hclwrite.Block, error) { + block := hclwrite.NewBlock(hcltype, labels) + + // Source and version require some special handling, should go at the top of a block declaration + sourceFound := false + versionFound := false + + // We need/want to guarantee the ordering of the attributes, do that here + var keys []string + for k := range attr { + switch k { + case "source": + sourceFound = true + case "version": + versionFound = true + default: + keys = append(keys, k) + } + } + sort.Strings(keys) + + if sourceFound || versionFound { + var newKeys []string + if sourceFound { + newKeys = append(newKeys, "source") + } + if versionFound { + newKeys = append(newKeys, "version") + } + keys = append(newKeys, keys...) + } + + // Write block data + for _, key := range keys { + val := attr[key] + if err := setBlockAttributeValue(block, key, val); err != nil { + return nil, err + } + } + + return block, nil +} + +// Create tokens for map of traversals. Used as a workaround for writing complex types where +// the built-in SetAttributeValue won't work. +func CreateMapTraversalTokens(input map[string]string) hclwrite.Tokens { + // Sort input + var keys []string + for k := range input { + keys = append(keys, k) + } + sort.Strings(keys) + + tokens := hclwrite.Tokens{ + {Type: hclsyntax.TokenOBrace, Bytes: []byte("{"), SpacesBefore: 1}, + {Type: hclsyntax.TokenNewline, Bytes: []byte("\n")}, + } + + for _, k := range keys { + tokens = append(tokens, []*hclwrite.Token{ + {Type: hclsyntax.TokenStringLit, Bytes: []byte(k)}, + {Type: hclsyntax.TokenEqual, Bytes: []byte("=")}, + {Type: hclsyntax.TokenStringLit, Bytes: []byte(" " + input[k]), SpacesBefore: 1}, + {Type: hclsyntax.TokenNewline, Bytes: []byte("\n")}, + }...) + } + + tokens = append(tokens, []*hclwrite.Token{ + {Type: hclsyntax.TokenNewline}, + {Type: hclsyntax.TokenCBrace, Bytes: []byte("}")}, + }...) + + return tokens +} + +// Create tokens for the for_each meta-argument. +func createForEachKey() hclwrite.Tokens { + return hclwrite.Tokens{ + {Type: hclsyntax.TokenStringLit, Bytes: []byte(" each.key"), SpacesBefore: 1}, + } +} + +// CreateHclStringOutput Convert blocks to a string. +func CreateHclStringOutput(blocks ...*hclwrite.Block) string { + file := hclwrite.NewEmptyFile() + body := file.Body() + blockCount := len(blocks) - 1 + + for i, b := range blocks { + if b != nil { + body.AppendBlock(b) + + // If this is not the last block, add a new line to provide spacing + if i < blockCount { + body.AppendNewline() + } + } + } + return string(file.Bytes()) +} + +// rootTerraformBlock is a helper that creates the literal `terraform{}` hcl block. +func rootTerraformBlock() (*hclwrite.Block, error) { + return HclCreateGenericBlock("terraform", nil, nil) +} + +// createRequiredProviders is a helper that creates the `required_providers` hcl block. +func createRequiredProviders(providers ...*HclRequiredProvider) (*hclwrite.Block, error) { + providerDetails := map[string]interface{}{} + for _, provider := range providers { + details := map[string]interface{}{} + if provider.Source() != "" { + details["source"] = provider.Source() + } + if provider.Version() != "" { + details["version"] = provider.Version() + } + providerDetails[provider.Name()] = details + } + + requiredProviders, err := HclCreateGenericBlock("required_providers", nil, providerDetails) + if err != nil { + return nil, err + } + + return requiredProviders, nil +} + +// CreateRequiredProviders Create required providers block. +func CreateRequiredProviders(providers ...*HclRequiredProvider) (*hclwrite.Block, error) { + block, err := rootTerraformBlock() + if err != nil { + return nil, err + } + + requiredProviders, err := createRequiredProviders(providers...) + if err != nil { + return nil, err + } + + block.Body().AppendBlock(requiredProviders) + return block, nil +} + +// CreateRequiredProviders Create required providers block. +func CreateRequiredProvidersWithCustomBlocks( + blocks []*hclwrite.Block, + providers ...*HclRequiredProvider, +) (*hclwrite.Block, error) { + block, err := rootTerraformBlock() + if err != nil { + return nil, err + } + + requiredProviders, err := createRequiredProviders(providers...) + if err != nil { + return nil, err + } + + block.Body().AppendBlock(requiredProviders) + for _, customBlock := range blocks { + block.Body().AppendBlock(customBlock) + } + + return block, nil +} + +// CombineHclBlocks Simple helper to combine multiple blocks (or slices of blocks) into a +// single slice to be rendered to string. +func CombineHclBlocks(results ...interface{}) []*hclwrite.Block { + blocks := []*hclwrite.Block{} + // Combine all blocks into single flat slice + for _, result := range results { + switch v := result.(type) { + case *hclwrite.Block: + if v != nil { + blocks = append(blocks, v) + } + case []*hclwrite.Block: + if len(v) > 0 { + blocks = append(blocks, v...) + } + default: + continue + } + } + + return blocks +} + +// CreateSimpleTraversal helper to create a hcl.Traversal in the order of supplied []string. +// +// e.g. []string{"a", "b", "c"} as input results in traversal having value a.b.c +func CreateSimpleTraversal(input ...string) hcl.Traversal { + var traverser []hcl.Traverser + + for i, val := range input { + if i == 0 { + traverser = append(traverser, hcl.TraverseRoot{Name: val}) + } else { + traverser = append(traverser, hcl.TraverseAttr{Name: val}) + } + } + return traverser +} + +// NewFuncCall wraps the function name around the traversal and returns hcl tokens +func NewFuncCall(funcName string, traversal hcl.Traversal) hclwrite.Tokens { + return hclwrite.TokensForFunctionCall(funcName, hclwrite.TokensForTraversal(traversal)) +} diff --git a/internal/tfgen/hcl_internal_test.go b/internal/tfgen/hcl_internal_test.go new file mode 100644 index 00000000..362a922e --- /dev/null +++ b/internal/tfgen/hcl_internal_test.go @@ -0,0 +1,109 @@ +// Copyright (c) Mondoo, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package tfgen + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/zclconf/go-cty/cty" +) + +func TestConvertTypeToCty(t *testing.T) { + t.Run("success_string", func(t *testing.T) { + val := "hello" + result, err := convertTypeToCty(val) + assert.NoError(t, err) + assert.Equal(t, cty.String, result.Type()) + assert.Equal(t, cty.StringVal(val), result) + }) + + t.Run("success_int", func(t *testing.T) { + val := 42 + result, err := convertTypeToCty(val) + assert.NoError(t, err) + assert.Equal(t, cty.Number, result.Type()) + assert.Equal(t, cty.NumberIntVal(int64(val)), result) + }) + + t.Run("success_bool", func(t *testing.T) { + val := true + result, err := convertTypeToCty(val) + assert.NoError(t, err) + assert.Equal(t, cty.Bool, result.Type()) + assert.Equal(t, cty.BoolVal(val), result) + }) + + t.Run("success_slice_of_strings", func(t *testing.T) { + val := []string{"apple", "banana", "cherry"} + result, err := convertTypeToCty(val) + assert.NoError(t, err) + expected := cty.ListVal([]cty.Value{ + cty.StringVal("apple"), + cty.StringVal("banana"), + cty.StringVal("cherry"), + }) + assert.Equal(t, cty.List(cty.String), result.Type()) + assert.Equal(t, expected, result) + }) + + t.Run("success_map_of_strings", func(t *testing.T) { + val := map[string]string{ + "key1": "value1", + "key2": "value2", + } + result, err := convertTypeToCty(val) + assert.NoError(t, err) + expected := cty.MapVal(map[string]cty.Value{ + "key1": cty.StringVal("value1"), + "key2": cty.StringVal("value2"), + }) + assert.Equal(t, cty.Map(cty.String), result.Type()) + assert.Equal(t, expected, result) + }) + + t.Run("success_empty_string", func(t *testing.T) { + val := "" + result, err := convertTypeToCty(val) + assert.NoError(t, err) + assert.Equal(t, cty.String, result.Type()) + assert.Equal(t, cty.StringVal(val), result) + }) + + t.Run("success_nil_value", func(t *testing.T) { + var val interface{} + result, err := convertTypeToCty(val) + assert.Error(t, err) + assert.Equal(t, cty.NilVal, result) + }) + + t.Run("error_unsupported_type", func(t *testing.T) { + val := struct{}{} // Unsupported type + result, err := convertTypeToCty(val) + assert.Error(t, err) + assert.Equal(t, cty.NilVal, result) + }) + + t.Run("success_empty_slice", func(t *testing.T) { + val := []string{} + result, err := convertTypeToCty(val) + assert.NoError(t, err) + assert.Equal(t, cty.List(cty.String), result.Type()) + assert.Equal(t, cty.ListValEmpty(cty.String), result) + }) + + t.Run("success_empty_map", func(t *testing.T) { + val := map[string]string{} + result, err := convertTypeToCty(val) + assert.NoError(t, err) + assert.Equal(t, cty.NilVal, result) + }) + + t.Run("success_large_int", func(t *testing.T) { + val := int64(9223372036854775807) // Max int64 value + result, err := convertTypeToCty(val) + assert.NoError(t, err) + assert.Equal(t, cty.NumberIntVal(val), result) + }) +} diff --git a/internal/tfgen/hcl_test.go b/internal/tfgen/hcl_test.go new file mode 100644 index 00000000..3f2a2d7f --- /dev/null +++ b/internal/tfgen/hcl_test.go @@ -0,0 +1,472 @@ +// Copyright (c) Mondoo, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package tfgen_test + +import ( + "testing" + + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclwrite" + "github.com/stretchr/testify/assert" + + "go.mondoo.com/cnspec/v11/internal/tfgen" +) + +func TestRealGcpHCLGeneration(t *testing.T) { + expectedOutput := `provider "mondoo" { + space = "hungry-poet-123456" +} + +provider "google" { + project = "prod-project-123" + region = "us-central1" +} + +resource "google_service_account" "mondoo" { + account_id = "mondoo-integration" + display_name = "Mondoo service account" +} + +resource "google_service_account_key" "mondoo" { + service_account_id = google_service_account.mondoo.name +} + +resource "mondoo_integration_gcp" "production" { + credentials = { + private_key = base64decode(google_service_account_key.mondoo.private_key) + } + name = "Production account" + project_id = "prod-project-123" +} +` + mondooProvider, err := tfgen.NewProvider("mondoo", tfgen.HclProviderWithAttributes( + map[string]interface{}{ + "space": "hungry-poet-123456", + }, + )).ToBlock() + assert.NoError(t, err) + googleProvider, err := tfgen.NewProvider("google", tfgen.HclProviderWithAttributes( + map[string]interface{}{ + "project": "prod-project-123", + "region": "us-central1", + }, + )).ToBlock() + assert.NoError(t, err) + googleServiceAccountResource, err := tfgen.NewResource("google_service_account", + "mondoo", tfgen.HclResourceWithAttributesAndProviderDetails( + map[string]interface{}{ + "account_id": "mondoo-integration", + "display_name": "Mondoo service account", + }, nil, + )).ToResourceBlock() + assert.NoError(t, err) + googleServiceAccountKey, err := tfgen.NewResource("google_service_account_key", + "mondoo", tfgen.HclResourceWithAttributesAndProviderDetails( + map[string]interface{}{ + "service_account_id": tfgen.CreateSimpleTraversal("google_service_account", "mondoo", "name"), + }, nil, + )).ToResourceBlock() + assert.NoError(t, err) + mondooIntegrationGCP, err := tfgen.NewResource("mondoo_integration_gcp", + "production", tfgen.HclResourceWithAttributesAndProviderDetails( + map[string]interface{}{ + "name": "Production account", + "project_id": "prod-project-123", + "credentials": map[string]interface{}{ + "private_key": tfgen.NewFuncCall( + "base64decode", tfgen.CreateSimpleTraversal("google_service_account_key", "mondoo", "private_key")), + }, + }, nil, + )).ToResourceBlock() + assert.NoError(t, err) + + blocksOutput := tfgen.CreateHclStringOutput( + tfgen.CombineHclBlocks( + mondooProvider, + googleProvider, + googleServiceAccountResource, + googleServiceAccountKey, + mondooIntegrationGCP, + )..., + ) + assert.Equal(t, expectedOutput, blocksOutput) + +} + +func TestProviderToBlock(t *testing.T) { + provider := tfgen.NewProvider("aws", tfgen.HclProviderWithAttributes(map[string]interface{}{ + "region": "us-west-2", + })) + + block, err := provider.ToBlock() + assert.NoError(t, err) + assert.NotNil(t, block) + assert.Equal(t, "provider", string(block.Type())) + expectedOutput := `provider "aws" { + region = "us-west-2" +} +` + assert.Equal(t, expectedOutput, tfgen.CreateHclStringOutput(block)) +} + +func TestProviderWithGenericBlocks(t *testing.T) { + subBlock := hclwrite.NewBlock("sub_block", []string{}) + provider := tfgen.NewProvider("aws", tfgen.HclProviderWithGenericBlocks(subBlock)) + + block, err := provider.ToBlock() + assert.NoError(t, err) + assert.NotNil(t, block) + assert.Len(t, block.Body().Blocks(), 1) + expectedOutput := `provider "aws" { + + sub_block { + } +} +` + assert.Equal(t, expectedOutput, tfgen.CreateHclStringOutput(block)) +} + +func TestNewRequiredProvider(t *testing.T) { + provider := tfgen.NewRequiredProvider("aws", + tfgen.HclRequiredProviderWithSource("hashicorp/aws"), + tfgen.HclRequiredProviderWithVersion("3.27.0"), + ) + + assert.Equal(t, "hashicorp/aws", provider.Source()) + assert.Equal(t, "3.27.0", provider.Version()) + assert.Equal(t, "aws", provider.Name()) +} + +func TestCreateRequiredProviders(t *testing.T) { + provider := tfgen.NewRequiredProvider("mondoo", tfgen.HclRequiredProviderWithSource("mondoohq/mondoo")) + block, err := tfgen.CreateRequiredProviders(provider) + + assert.NoError(t, err) + assert.NotNil(t, block) + assert.Equal(t, "terraform", string(block.Type())) + expectedOutput := `terraform { + required_providers { + mondoo = { + source = "mondoohq/mondoo" + } + } +} +` + assert.Equal(t, expectedOutput, tfgen.CreateHclStringOutput(block)) +} + +func TestNewOutput(t *testing.T) { + output := tfgen.NewOutput("test_output", + []string{"aws_instance", "example", "id"}, + "Example description", + ) + expectedOutput := `output "test_output" { + description = "Example description" + value = aws_instance.example.id +} +` + + block, err := output.ToBlock() + assert.NoError(t, err) + assert.NotNil(t, block) + assert.Equal(t, "output", string(block.Type())) + assert.Equal(t, expectedOutput, tfgen.CreateHclStringOutput(block)) +} +func TestHclResourceToBlock(t *testing.T) { + resource := tfgen.NewResource("aws_instance", + "example", tfgen.HclResourceWithAttributesAndProviderDetails( + map[string]interface{}{"ami": "ami-123456"}, + []string{"aws.foo"}, + )) + expectedOutput := `resource "aws_instance" "example" { + ami = "ami-123456" + + provider = aws.foo +} +` + block, err := resource.ToResourceBlock() + assert.NoError(t, err) + assert.NotNil(t, block) + assert.Equal(t, "resource", string(block.Type())) + assert.Equal(t, expectedOutput, tfgen.CreateHclStringOutput(block)) +} + +func TestCombineHclBlocks(t *testing.T) { + block1 := hclwrite.NewBlock("block1", nil) + block2 := hclwrite.NewBlock("block2", nil) + + combined := tfgen.CombineHclBlocks(block1, block2) + assert.Len(t, combined, 2) +} + +func TestCreateSimpleTraversal(t *testing.T) { + traversal := tfgen.CreateSimpleTraversal("aws_instance", "example", "id") + assert.Len(t, traversal, 3) + assert.Equal(t, "aws_instance", traversal.RootName()) +} + +func TestGenericBlockCreation(t *testing.T) { + t.Run("should be a working generic block", func(t *testing.T) { + data, err := tfgen.HclCreateGenericBlock( + "thing", + []string{"a", "b"}, + map[string]interface{}{ + "a": "foo", + "b": 1, + "c": false, + "d": map[string]interface{}{ // Order of map elements should be sorted when executed + "f": 1, + "g": "bar", + "e": true, + }, + "h": hcl.Traversal{ + hcl.TraverseRoot{ + Name: "module", + }, + hcl.TraverseAttr{ + Name: "example", + }, + hcl.TraverseAttr{ + Name: "value", + }, + }, + "i": []string{"one", "two", "three"}, + "j": []interface{}{"one", 2, true}, + "k": []interface{}{ + map[string]interface{}{"test1": []string{"f", "o", "o"}}, + map[string]interface{}{"test2": []string{"b", "a", "r"}}, + }, + }, + ) + + assert.Nil(t, err) + assert.Equal(t, "thing", data.Type()) + assert.Equal(t, "a", data.Labels()[0]) + assert.Equal(t, "b", data.Labels()[1]) + expectedOutput := `thing "a" "b" { + a = "foo" + b = 1 + c = false + d = { + e = true + f = 1 + g = "bar" + } + h = module.example.value + i = ["one", "two", "three"] + j = ["one", 2, true] + k = [{ + test1 = ["f", "o", "o"] + }, { + test2 = ["b", "a", "r"] + }] +} +` + assert.Equal(t, expectedOutput, tfgen.CreateHclStringOutput(data)) + + }) + t.Run("should fail to construct generic block with mismatched list element types", func(t *testing.T) { + _, err := tfgen.HclCreateGenericBlock( + "thing", + []string{}, + map[string]interface{}{ + "k": []map[string]interface{}{ // can use []interface{} here to support this sort of structure, but as-is will fail + {"test1": []string{"f", "o", "o"}}, + {"test2": []string{"b", "a", "r"}}, + }, + }, + ) + + assert.Error(t, err, "should fail to generate block with mismatched list element types") + }) +} + +func TestModuleBlock(t *testing.T) { + data, err := tfgen.NewModule("foo", + "mycorp/mycloud", + tfgen.HclModuleWithVersion("~> 0.1"), + tfgen.HclModuleWithAttributes(map[string]interface{}{"bar": "foo"})).ToBlock() + + assert.Nil(t, err) + assert.Equal(t, "module", data.Type()) + assert.Equal(t, "foo", data.Labels()[0]) + assert.Equal(t, + "version=\"~> 0.1\"\n", + string(data.Body().GetAttribute("version").BuildTokens(nil).Bytes()), + ) + assert.Equal(t, + "bar=\"foo\"\n", + string(data.Body().GetAttribute("bar").BuildTokens(nil).Bytes()), + ) +} + +func TestModuleWithProviderBlock(t *testing.T) { + providerDetails := map[string]string{ + "foo.src": "test.abc", + "foo.dst": "abc.test", + } + + data, err := tfgen.NewModule("foo", + "mycorp/mycloud", + tfgen.HclModuleWithProviderDetails(providerDetails)).ToBlock() + + assert.Nil(t, err) + assert.Equal(t, "module", data.Type()) + assert.Equal(t, "foo", data.Labels()[0]) + assert.Equal(t, + "providers= {\nfoo.dst= abc.test\nfoo.src= test.abc\n}\n", + string(data.Body().GetAttribute("providers").BuildTokens(nil).Bytes())) +} + +func TestProviderBlock(t *testing.T) { + attrs := map[string]interface{}{"key": "value"} + data, err := tfgen.NewProvider("foo", tfgen.HclProviderWithAttributes(attrs)).ToBlock() + + assert.Nil(t, err) + assert.Equal(t, "provider", data.Type()) + assert.Equal(t, "foo", data.Labels()[0]) + assert.Equal(t, "key=\"value\"\n", string(data.Body().GetAttribute("key").BuildTokens(nil).Bytes())) +} + +func TestProviderBlockWithTraversal(t *testing.T) { + attrs := map[string]interface{}{ + "test": hcl.Traversal{ + hcl.TraverseRoot{Name: "key"}, + hcl.TraverseAttr{Name: "value"}, + }} + data, err := tfgen.NewProvider("foo", tfgen.HclProviderWithAttributes(attrs)).ToBlock() + + assert.Nil(t, err) + assert.Equal(t, "provider", data.Type()) + assert.Equal(t, "foo", data.Labels()[0]) + assert.Equal(t, "test=key.value\n", string(data.Body().GetAttribute("test").BuildTokens(nil).Bytes())) +} + +func TestRequiredProvidersBlock(t *testing.T) { + provider1 := tfgen.NewRequiredProvider("foo", + tfgen.HclRequiredProviderWithSource("test/test")) + provider2 := tfgen.NewRequiredProvider("bar", + tfgen.HclRequiredProviderWithVersion("~> 0.1")) + provider3 := tfgen.NewRequiredProvider("mondoo", + tfgen.HclRequiredProviderWithSource("mondoohq/mondoo"), + tfgen.HclRequiredProviderWithVersion("~> 0.19")) + data, err := tfgen.CreateRequiredProviders(provider1, provider2, provider3) + assert.Nil(t, err) + + expectedOutput := `terraform { + required_providers { + bar = { + version = "~> 0.1" + } + foo = { + source = "test/test" + } + mondoo = { + source = "mondoohq/mondoo" + version = "~> 0.19" + } + } +} +` + + assert.Equal(t, expectedOutput, tfgen.CreateHclStringOutput(data)) +} + +func TestRequiredProvidersBlockWithCustomBlocks(t *testing.T) { + provider1 := tfgen.NewRequiredProvider("foo", + tfgen.HclRequiredProviderWithSource("test/test")) + provider2 := tfgen.NewRequiredProvider("bar", + tfgen.HclRequiredProviderWithVersion("~> 0.1")) + provider3 := tfgen.NewRequiredProvider("mondoo", + tfgen.HclRequiredProviderWithSource("mondoohq/mondoo"), + tfgen.HclRequiredProviderWithVersion("~> 0.19")) + + customBlock, err := tfgen.HclCreateGenericBlock("backend", []string{"s3"}, nil) + assert.NoError(t, err) + data, err := tfgen.CreateRequiredProvidersWithCustomBlocks([]*hclwrite.Block{customBlock}, provider1, provider2, provider3) + assert.Nil(t, err) + + expectedOutput := `terraform { + required_providers { + bar = { + version = "~> 0.1" + } + foo = { + source = "test/test" + } + mondoo = { + source = "mondoohq/mondoo" + version = "~> 0.19" + } + } + backend "s3" { + } +} +` + assert.Equal(t, expectedOutput, tfgen.CreateHclStringOutput(data)) +} + +func TestOutputBlockCreation(t *testing.T) { + t.Run("should generate correct block for simple output with no description", func(t *testing.T) { + o := tfgen.NewOutput("test", []string{"test", "one", "two"}, "") + b, err := o.ToBlock() + assert.NoError(t, err) + str := tfgen.CreateHclStringOutput(b) + assert.Equal(t, "output \"test\" {\n value = test.one.two\n}\n", str) + }) + t.Run("should generate correct block for simple output with description", func(t *testing.T) { + o := tfgen.NewOutput("test", []string{"test", "one", "two"}, "test description") + b, err := o.ToBlock() + assert.NoError(t, err) + str := tfgen.CreateHclStringOutput(b) + assert.Equal(t, "output \"test\" {\n description = \"test description\"\n value = test.one.two\n}\n", str) + }) +} + +func TestModuleToBlock(t *testing.T) { + module := tfgen.NewModule("vpc", "terraform-aws-modules/vpc/aws", + tfgen.HclModuleWithAttributes(map[string]interface{}{ + "version": "2.32.0", + }), + ) + + block, err := module.ToBlock() + assert.NoError(t, err) + assert.NotNil(t, block) + assert.Equal(t, "module", string(block.Type())) + expectedOutput := `module "vpc" { + source = "terraform-aws-modules/vpc/aws" + version = "2.32.0" +} +` + assert.Equal(t, expectedOutput, tfgen.CreateHclStringOutput(block)) +} + +func TestModuleWithForEach(t *testing.T) { + forEachValue := map[string]string{ + "dev": "us-west-1", + "prod": "us-west-2", + } + + module := tfgen.NewModule("vpc", + "terraform-aws-modules/vpc/aws", + tfgen.HclModuleWithForEach("env", forEachValue), + ) + + block, err := module.ToBlock() + assert.NoError(t, err) + assert.NotNil(t, block) + assert.Len(t, block.Body().Attributes(), 3) + expectedOutput := `module "vpc" { + source = "terraform-aws-modules/vpc/aws" + + for_each = { + dev = "us-west-1" + prod = "us-west-2" + } + env = each.key +} +` + assert.Equal(t, expectedOutput, tfgen.CreateHclStringOutput(block)) +}