diff --git a/acceptance/scenario_generate_test.go b/acceptance/scenario_generate_test.go index edee2128..99dfd61e 100644 --- a/acceptance/scenario_generate_test.go +++ b/acceptance/scenario_generate_test.go @@ -66,6 +66,12 @@ func TestAcc_Cmd_Scenario_Generate(t *testing.T) { [][]string{{"skip", "keep"}}, fmt.Sprintf("%x", sha256.Sum256([]byte("path [skip:keep]"))), }, + { + "scenario_generate_complex_provider", + "kubernetes", + [][]string{}, + fmt.Sprintf("%x", sha256.Sum256([]byte("kubernetes"))), + }, } { t.Run(fmt.Sprintf("%s %s %s", test.dir, test.name, test.variants), func(t *testing.T) { outDir := filepath.Join(tmpDir, test.dir) diff --git a/acceptance/scenarios/scenario_generate_complex_provider/enos.hcl b/acceptance/scenarios/scenario_generate_complex_provider/enos.hcl new file mode 100644 index 00000000..96d80592 --- /dev/null +++ b/acceptance/scenarios/scenario_generate_complex_provider/enos.hcl @@ -0,0 +1,33 @@ +terraform "default" { + required_version = ">= 1.0.0" + + required_providers { + kubernetes = { + source = "hashicorp/kubernetes" + } + } +} + +module "kubernetes" { + source = "./modules/kubernetes" +} + +provider "kubernetes" "default" { + host = "http://example.com" + cluster_ca_certificate = "base64cert" + exec { + api_version = "client.authentication.k8s.io/v1alpha1" + args = ["eks", "get-token", "--cluster-name", "foo"] + command = "aws" + } +} + +scenario "kubernetes" { + providers = [ + provider.kubernetes.default + ] + + step "kubernetes" { + module = module.kubernetes + } +} diff --git a/acceptance/scenarios/scenario_generate_complex_provider/enos.vars.hcl b/acceptance/scenarios/scenario_generate_complex_provider/enos.vars.hcl new file mode 100644 index 00000000..e69de29b diff --git a/acceptance/scenarios/scenario_generate_complex_provider/modules/kubernetes/main.tf b/acceptance/scenarios/scenario_generate_complex_provider/modules/kubernetes/main.tf new file mode 100644 index 00000000..8aed4cb0 --- /dev/null +++ b/acceptance/scenarios/scenario_generate_complex_provider/modules/kubernetes/main.tf @@ -0,0 +1,3 @@ +output "random" { + value = "notactuallyrandom" +} diff --git a/internal/flightplan/flightplan.go b/internal/flightplan/flightplan.go index d80877ba..4e4858f9 100644 --- a/internal/flightplan/flightplan.go +++ b/internal/flightplan/flightplan.go @@ -328,7 +328,7 @@ func (fp *FlightPlan) decodeTerraformCLIs(ctx *hcl.EvalContext) hcl.Diagnostics // top-level schema. func (fp *FlightPlan) decodeProviders(ctx *hcl.EvalContext) hcl.Diagnostics { var diags hcl.Diagnostics - // type -> map of aliases -> provider object + // provider type -> alias name -> provider object value providers := map[string]map[string]cty.Value{} for _, block := range fp.BodyContent.Blocks.OfType(blockTypeProvider) { diff --git a/internal/flightplan/provider.go b/internal/flightplan/provider.go index 6cee4dca..202a33b8 100644 --- a/internal/flightplan/provider.go +++ b/internal/flightplan/provider.go @@ -13,15 +13,15 @@ import ( // Provider is a Enos transport configuration type Provider struct { - Type string `cty:"type"` - Alias string `cty:"alias"` - Attrs map[string]cty.Value `cty:"attrs"` + Type string `cty:"type"` + Alias string `cty:"alias"` + Config *SchemalessBlock `cty:"config"` } // NewProvider returns a new Provider func NewProvider() *Provider { return &Provider{ - Attrs: map[string]cty.Value{}, + Config: NewSchemalessBlock(), } } @@ -36,27 +36,19 @@ func (p *Provider) decode(block *hcl.Block, ctx *hcl.EvalContext) hcl.Diagnostic if p.Type == "enos" { // Since we know the schema for the "enos" provider we can more fine - // grained decoding. + // grained decoding and validation. moreDiags := p.decodeEnosProvider(block, ctx) diags = diags.Extend(moreDiags) if moreDiags.HasErrors() { return diags } } else { - attrs, moreDiags := block.Body.JustAttributes() + // Decode the entire provider block as a schemaless block + moreDiags := p.Config.Decode(block, ctx) diags = diags.Extend(moreDiags) if moreDiags.HasErrors() { return diags } - - for name, attr := range attrs { - val, moreDiags := attr.Expr.Value(ctx) - diags = diags.Extend(moreDiags) - if moreDiags.HasErrors() { - continue - } - p.Attrs[name] = val - } } return diags @@ -64,23 +56,18 @@ func (p *Provider) decode(block *hcl.Block, ctx *hcl.EvalContext) hcl.Diagnostic // ToCtyValue returns the provider contents as an object cty.Value. func (p *Provider) ToCtyValue() cty.Value { - vals := map[string]cty.Value{ - "type": cty.StringVal(p.Type), - "alias": cty.StringVal(p.Alias), - } - - if len(p.Attrs) > 0 { - vals["attrs"] = cty.ObjectVal(p.Attrs) - } else { - vals["attrs"] = cty.NullVal(cty.EmptyObject) - } - - return cty.ObjectVal(vals) + return cty.ObjectVal(map[string]cty.Value{ + "type": cty.StringVal(p.Type), + "alias": cty.StringVal(p.Alias), + "config": p.Config.ToCtyValue(), + }) } // FromCtyValue takes a cty.Value and unmarshals it onto itself. It expects // a valid object created from ToCtyValue() func (p *Provider) FromCtyValue(val cty.Value) error { + var err error + if val.IsNull() { return nil } @@ -105,13 +92,10 @@ func (p *Provider) FromCtyValue(val cty.Value) error { return fmt.Errorf("provider alias must be a string ") } p.Alias = val.AsString() - case "attrs": - if !val.CanIterateElements() { - return fmt.Errorf("provider attrs must a map of attributes") - } - - for k, v := range val.AsValueMap() { - p.Attrs[k] = v + case "config": + err = p.Config.FromCtyValue(val) + if err != nil { + return err } default: return fmt.Errorf("unknown key in value object: %s", key) @@ -141,7 +125,17 @@ func (p *Provider) decodeEnosProvider(block *hcl.Block, ctx *hcl.EvalContext) hc "user", "host", "private_key", "private_key_path", "passphrase", "passphrase_path", }), - }, []string{"ssh"}), + "kubernetes": cty.ObjectWithOptionalAttrs(map[string]cty.Type{ + "kubeconfig_base64": cty.String, + "context_name": cty.String, + "namespace": cty.String, + "pod": cty.String, + "container": cty.String, + }, []string{ + "kubeconfig_base64", "context_name", "namespace", "pod", + "container", + }), + }, []string{"ssh", "kubernetes"}), }, } @@ -164,8 +158,14 @@ func (p *Provider) decodeEnosProvider(block *hcl.Block, ctx *hcl.EvalContext) hc return diags } + // We should have either a valid k8s or ssh transport. + p.Config.Type = "provider" + p.Config.Labels = []string{p.Type, p.Alias} + p.Config.Attrs["transport"] = trans + ssh, ok := trans.AsValueMap()["ssh"] if !ok { + // We're done, we're not going to do anything else with k8s return diags } @@ -173,6 +173,8 @@ func (p *Provider) decodeEnosProvider(block *hcl.Block, ctx *hcl.EvalContext) hc return diags } + // We have an ssh transport. Make sure any of the paths that we've been + // given exist. sshVals := map[string]cty.Value{} for name, val := range ssh.AsValueMap() { // Only pass through known values @@ -224,7 +226,7 @@ func (p *Provider) decodeEnosProvider(block *hcl.Block, ctx *hcl.EvalContext) hc } } - p.Attrs["transport"] = cty.ObjectVal(map[string]cty.Value{ + p.Config.Attrs["transport"] = cty.ObjectVal(map[string]cty.Value{ "ssh": cty.ObjectVal(sshVals), }) diff --git a/internal/flightplan/provider_test.go b/internal/flightplan/provider_test.go index c32bfa78..0350cdaa 100644 --- a/internal/flightplan/provider_test.go +++ b/internal/flightplan/provider_test.go @@ -38,29 +38,6 @@ scenario "backend" { module = module.backend } } -`, modulePath), - }, - { - desc: "invalid block", - fail: true, - hcl: fmt.Sprintf(` -provider "foo" "bar" { - invalid_block "foo" { - foo = "bar" - } -} - -module "backend" { - source = "%s" -} - -scenario "backend" { - providers = [provider.foo.bar] - - step "first" { - module = module.backend - } -} `, modulePath), }, { @@ -145,13 +122,18 @@ scenario "test" { { Type: "enos", Alias: "ubuntu", - Attrs: map[string]cty.Value{ - "transport": cty.ObjectVal(map[string]cty.Value{ - "ssh": cty.ObjectVal(map[string]cty.Value{ - "user": cty.StringVal("ubuntu"), - "private_key": cty.StringVal("supersecret"), + Config: &SchemalessBlock{ + Type: "provider", + Labels: []string{"enos", "ubuntu"}, + Attrs: map[string]cty.Value{ + "transport": cty.ObjectVal(map[string]cty.Value{ + "ssh": cty.ObjectVal(map[string]cty.Value{ + "user": cty.StringVal("ubuntu"), + "private_key": cty.StringVal("supersecret"), + }), }), - }), + }, + Children: []*SchemalessBlock{}, }, }, }, @@ -175,13 +157,18 @@ scenario "test" { { Type: "enos", Alias: "ubuntu", - Attrs: map[string]cty.Value{ - "transport": cty.ObjectVal(map[string]cty.Value{ - "ssh": cty.ObjectVal(map[string]cty.Value{ - "user": cty.StringVal("ubuntu"), - "private_key": cty.StringVal("supersecret"), + Config: &SchemalessBlock{ + Type: "provider", + Labels: []string{"enos", "ubuntu"}, + Attrs: map[string]cty.Value{ + "transport": cty.ObjectVal(map[string]cty.Value{ + "ssh": cty.ObjectVal(map[string]cty.Value{ + "user": cty.StringVal("ubuntu"), + "private_key": cty.StringVal("supersecret"), + }), }), - }), + }, + Children: []*SchemalessBlock{}, }, }, }, @@ -199,13 +186,18 @@ scenario "test" { "enos": { Type: "enos", Alias: "ubuntu", - Attrs: map[string]cty.Value{ - "transport": cty.ObjectVal(map[string]cty.Value{ - "ssh": cty.ObjectVal(map[string]cty.Value{ - "user": cty.StringVal("ubuntu"), - "private_key": cty.StringVal("supersecret"), + Config: &SchemalessBlock{ + Type: "provider", + Labels: []string{"enos", "ubuntu"}, + Attrs: map[string]cty.Value{ + "transport": cty.ObjectVal(map[string]cty.Value{ + "ssh": cty.ObjectVal(map[string]cty.Value{ + "user": cty.StringVal("ubuntu"), + "private_key": cty.StringVal("supersecret"), + }), }), - }), + }, + Children: []*SchemalessBlock{}, }, }, }, @@ -230,16 +222,35 @@ provider "aws" "eu" { region = "eu-west-1" } +provider "kubernetes" "default" { + host = "eks.host.com" + cluster_ca_certificate = "base64cert" + exec { + api_version = "client.authentication.k8s.io/v1alpha1" + args = ["eks", "get-token", "--cluster-name", "my-cluster"] + command = "aws" + + not_a_real_block_but_testing_nested_things { + nested_attr = "value" + } + } +} + module "copy" { source = "%s" driver = "s3" } +module "k8s_deploy" { + source = "%[1]s" + driver = "k8s" +} + scenario "copy_to_east" { providers = [ provider.aws.west, - "aws.east", + "aws.east", ] step "copy" { @@ -265,29 +276,89 @@ scenario "copy_to_eu" { } } } -`, modulePath), + +scenario "k8s" { + providers = [ + provider.kubernetes.default, + ] + + step "deploy" { + module = module.k8s_deploy + } +}`, modulePath), expected: &FlightPlan{ Providers: []*Provider{ { - Type: "aws", - + Type: "aws", Alias: "east", - Attrs: map[string]cty.Value{ - "region": cty.StringVal("us-east-1"), + Config: &SchemalessBlock{ + Type: "provider", + Labels: []string{"aws", "east"}, + Attrs: map[string]cty.Value{ + "region": cty.StringVal("us-east-1"), + }, + Children: []*SchemalessBlock{}, }, }, { Type: "aws", Alias: "west", - Attrs: map[string]cty.Value{ - "region": cty.StringVal("us-west-1"), + Config: &SchemalessBlock{ + Type: "provider", + Labels: []string{"aws", "west"}, + Attrs: map[string]cty.Value{ + "region": cty.StringVal("us-west-1"), + }, + Children: []*SchemalessBlock{}, }, }, { Type: "aws", Alias: "eu", - Attrs: map[string]cty.Value{ - "region": cty.StringVal("eu-west-1"), + Config: &SchemalessBlock{ + Type: "provider", + Labels: []string{"aws", "eu"}, + Attrs: map[string]cty.Value{ + "region": cty.StringVal("eu-west-1"), + }, + Children: []*SchemalessBlock{}, + }, + }, + { + Type: "kubernetes", + Alias: "default", + Config: &SchemalessBlock{ + Type: "provider", + Labels: []string{"kubernetes", "default"}, + Attrs: map[string]cty.Value{ + "host": cty.StringVal("eks.host.com"), + "cluster_ca_certificate": cty.StringVal("base64cert"), + }, + Children: []*SchemalessBlock{ + { + Type: "exec", + Labels: []string{}, + Attrs: map[string]cty.Value{ + "api_version": cty.StringVal("client.authentication.k8s.io/v1alpha1"), + "args": cty.ListVal([]cty.Value{ + cty.StringVal("eks"), + cty.StringVal("get-token"), + cty.StringVal("--cluster-name"), + cty.StringVal("my-cluster"), + }), + "command": cty.StringVal("aws"), + }, + Children: []*SchemalessBlock{ + { + Type: "not_a_real_block_but_testing_nested_things", + Labels: []string{"with", "labels"}, + Attrs: map[string]cty.Value{ + "nested_attr": cty.StringVal("value"), + }, + }, + }, + }, + }, }, }, }, @@ -302,6 +373,13 @@ scenario "copy_to_eu" { "driver": cty.StringVal("s3"), }, }, + { + Name: "k8s_deploy", + Source: modulePath, + Attrs: map[string]cty.Value{ + "driver": cty.StringVal("k8s"), + }, + }, }, Scenarios: []*Scenario{ { @@ -310,16 +388,26 @@ scenario "copy_to_eu" { Providers: []*Provider{ { Type: "aws", - Alias: "east", - Attrs: map[string]cty.Value{ - "region": cty.StringVal("us-east-1"), + Alias: "west", + Config: &SchemalessBlock{ + Type: "provider", + Labels: []string{"aws", "west"}, + Attrs: map[string]cty.Value{ + "region": cty.StringVal("us-west-1"), + }, + Children: []*SchemalessBlock{}, }, }, { Type: "aws", - Alias: "west", - Attrs: map[string]cty.Value{ - "region": cty.StringVal("us-west-1"), + Alias: "east", + Config: &SchemalessBlock{ + Type: "provider", + Labels: []string{"aws", "east"}, + Attrs: map[string]cty.Value{ + "region": cty.StringVal("us-east-1"), + }, + Children: []*SchemalessBlock{}, }, }, }, @@ -337,16 +425,25 @@ scenario "copy_to_eu" { "src": { Type: "aws", Alias: "west", - Attrs: map[string]cty.Value{ - "region": cty.StringVal("us-west-1"), + Config: &SchemalessBlock{ + Type: "provider", + Labels: []string{"aws", "west"}, + Attrs: map[string]cty.Value{ + "region": cty.StringVal("us-west-1"), + }, + Children: []*SchemalessBlock{}, }, }, - "dst": { Type: "aws", Alias: "east", - Attrs: map[string]cty.Value{ - "region": cty.StringVal("us-east-1"), + Config: &SchemalessBlock{ + Type: "provider", + Labels: []string{"aws", "east"}, + Attrs: map[string]cty.Value{ + "region": cty.StringVal("us-east-1"), + }, + Children: []*SchemalessBlock{}, }, }, }, @@ -360,15 +457,25 @@ scenario "copy_to_eu" { { Type: "aws", Alias: "east", - Attrs: map[string]cty.Value{ - "region": cty.StringVal("us-east-1"), + Config: &SchemalessBlock{ + Type: "provider", + Labels: []string{"aws", "east"}, + Attrs: map[string]cty.Value{ + "region": cty.StringVal("us-east-1"), + }, + Children: []*SchemalessBlock{}, }, }, { Type: "aws", Alias: "eu", - Attrs: map[string]cty.Value{ - "region": cty.StringVal("eu-west-1"), + Config: &SchemalessBlock{ + Type: "provider", + Labels: []string{"aws", "eu"}, + Attrs: map[string]cty.Value{ + "region": cty.StringVal("eu-west-1"), + }, + Children: []*SchemalessBlock{}, }, }, }, @@ -386,21 +493,87 @@ scenario "copy_to_eu" { "src": { Type: "aws", Alias: "east", - Attrs: map[string]cty.Value{ - "region": cty.StringVal("us-east-1"), + Config: &SchemalessBlock{ + Type: "provider", + Labels: []string{"aws", "east"}, + Attrs: map[string]cty.Value{ + "region": cty.StringVal("us-east-1"), + }, + Children: []*SchemalessBlock{}, }, }, "dst": { Type: "aws", Alias: "eu", - Attrs: map[string]cty.Value{ - "region": cty.StringVal("eu-west-1"), + Config: &SchemalessBlock{ + Type: "provider", + Labels: []string{"aws", "eu"}, + Attrs: map[string]cty.Value{ + "region": cty.StringVal("eu-west-1"), + }, + Children: []*SchemalessBlock{}, }, }, }, }, }, }, + { + Name: "k8s", + TerraformCLI: DefaultTerraformCLI(), + Providers: []*Provider{ + { + Type: "kubernetes", + Alias: "default", + Config: &SchemalessBlock{ + Type: "provider", + Labels: []string{"kubernetes", "default"}, + Attrs: map[string]cty.Value{ + "host": cty.StringVal("eks.host.com"), + "cluster_ca_certificate": cty.StringVal("base64cert"), + }, + Children: []*SchemalessBlock{ + { + Type: "exec", + Labels: []string{}, + Attrs: map[string]cty.Value{ + "api_version": cty.StringVal("client.authentication.k8s.io/v1alpha1"), + "args": cty.ListVal([]cty.Value{ + cty.StringVal("eks"), + cty.StringVal("get-token"), + cty.StringVal("--cluster-name"), + cty.StringVal("my-cluster"), + }), + "command": cty.StringVal("aws"), + }, + Children: []*SchemalessBlock{ + { + Type: "not_a_real_block_but_testing_nested_things", + Labels: []string{"with", "labels"}, + Attrs: map[string]cty.Value{ + "nested_attr": cty.StringVal("value"), + }, + Children: []*SchemalessBlock{}, + }, + }, + }, + }, + }, + }, + }, + Steps: []*ScenarioStep{ + { + Name: "deploy", + Module: &Module{ + Name: "k8s_deploy", + Source: modulePath, + Attrs: map[string]cty.Value{ + "driver": testMakeStepVarValue(cty.StringVal("k8s")), + }, + }, + }, + }, + }, }, }, }, @@ -418,17 +591,72 @@ scenario "copy_to_eu" { } func Test_Provider_Cty_RoundTrip(t *testing.T) { - provider := &Provider{ - Type: "aws", - Alias: "west", - Attrs: map[string]cty.Value{ - "region": cty.StringVal("us-west-1"), - "access_key": cty.StringVal("key"), - "secret_key": cty.StringVal("secret"), + for _, p := range []struct { + testName string + provider *Provider + }{ + { + "aws", + &Provider{ + Type: "aws", + Alias: "west", + Config: &SchemalessBlock{ + Labels: []string{}, + Attrs: map[string]cty.Value{ + "region": cty.StringVal("us-west-1"), + "access_key": cty.StringVal("key"), + "secret_key": cty.StringVal("secret"), + }, + Children: []*SchemalessBlock{}, + }, + }, }, + { + "k8s", + &Provider{ + Type: "kubernetes", + Alias: "default", + Config: &SchemalessBlock{ + Type: "provider", + Labels: []string{"kubernetes", "default"}, + Attrs: map[string]cty.Value{ + "host": cty.StringVal("eks.host.com"), + "cluster_ca_certificate": cty.StringVal("base64cert"), + }, + Children: []*SchemalessBlock{ + { + Type: "exec", + Labels: []string{}, + Attrs: map[string]cty.Value{ + "api_version": cty.StringVal("client.authentication.k8s.io/v1alpha1"), + "args": cty.ListVal([]cty.Value{ + cty.StringVal("eks"), + cty.StringVal("get-token"), + cty.StringVal("--cluster-name"), + cty.StringVal("my-cluster"), + }), + "command": cty.StringVal("aws"), + }, + Children: []*SchemalessBlock{ + { + Type: "not_a_real_block_but_testing_nested_things", + Labels: []string{"with", "labels"}, + Attrs: map[string]cty.Value{ + "nested_attr": cty.StringVal("value"), + }, + Children: []*SchemalessBlock{}, + }, + }, + }, + }, + }, + }, + }, + } { + t.Run(p.testName, func(t *testing.T) { + clone := NewProvider() + require.NoError(t, clone.FromCtyValue(p.provider.ToCtyValue())) + require.EqualValues(t, p.provider, clone) + }) } - - clone := NewProvider() - require.NoError(t, clone.FromCtyValue(provider.ToCtyValue())) - require.EqualValues(t, provider, clone) } diff --git a/internal/flightplan/schemaless_block.go b/internal/flightplan/schemaless_block.go new file mode 100644 index 00000000..8b5995de --- /dev/null +++ b/internal/flightplan/schemaless_block.go @@ -0,0 +1,166 @@ +package flightplan + +import ( + "fmt" + + "github.com/zclconf/go-cty/cty" + + hcl "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" +) + +// SchemalessBlock is our value on HCL block that has no known schema +type SchemalessBlock struct { + Type string `cty:"type"` + Labels []string `cty:"labels"` + Attrs map[string]cty.Value `cty:"attrs"` + Children []*SchemalessBlock `cty:"blocks"` +} + +// NewSchemalessBlock takes a block type and any labels and returns a new +// schemaless block. +func NewSchemalessBlock() *SchemalessBlock { + return &SchemalessBlock{ + Labels: []string{}, + Attrs: map[string]cty.Value{}, + Children: []*SchemalessBlock{}, + } +} + +// Decode takes in an HCL block and eval context and attempts to decode and +// evaluate it. +func (s *SchemalessBlock) Decode(block *hcl.Block, ctx *hcl.EvalContext) hcl.Diagnostics { + var diags hcl.Diagnostics + + s.Type = block.Type + s.Labels = block.Labels + + // We need to cast this to an hclsyntax body to get access to the blocks. + // It also helps us get the attribute values without generating diagnostics. + body, ok := block.Body.(*hclsyntax.Body) + if !ok { + // This should never happen + return diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "unable to decode block", + Detail: "unable to cast block to the hclsyntax", + Subject: block.TypeRange.Ptr(), + EvalContext: ctx, + }) + } + + for name, attr := range body.Attributes { + val, moreDiags := attr.AsHCLAttribute().Expr.Value(ctx) + diags = diags.Extend(moreDiags) + if moreDiags.HasErrors() { + continue + } + s.Attrs[name] = val + } + + for _, child := range body.Blocks { + csb := NewSchemalessBlock() + moreDiags := csb.Decode(child.AsHCLBlock(), ctx) + diags = diags.Extend(moreDiags) + if moreDiags.HasErrors() { + return diags + } + s.Children = append(s.Children, csb) + } + + return diags +} + +// ToCtyValue returns the schemaless block contents as an object cty.Value. +func (s *SchemalessBlock) ToCtyValue() cty.Value { + vals := map[string]cty.Value{ + "type": cty.StringVal(s.Type), + "labels": cty.ListValEmpty(cty.String), + "attrs": cty.NullVal(cty.EmptyObject), + "blocks": cty.ListValEmpty(cty.EmptyObject), + } + + labels := []cty.Value{} + for _, l := range s.Labels { + labels = append(labels, cty.StringVal(l)) + } + if len(labels) > 0 { + vals["labels"] = cty.ListVal(labels) + } + + if len(s.Attrs) > 0 { + vals["attrs"] = cty.ObjectVal(s.Attrs) + } + + if len(s.Children) > 0 { + blocks := []cty.Value{} + for _, b := range s.Children { + blocks = append(blocks, b.ToCtyValue()) + } + vals["blocks"] = cty.ListVal(blocks) + } + + return cty.ObjectVal(vals) +} + +// FromCtyValue takes a cty.Value and unmarshals it onto itself. It expects +// a valid object created from ToCtyValue() +func (s *SchemalessBlock) FromCtyValue(val cty.Value) error { + if val.IsNull() { + return nil + } + + if !val.IsWhollyKnown() { + return fmt.Errorf("cannot unmarshal unknown value") + } + + if !val.CanIterateElements() { + return fmt.Errorf("value must be an object") + } + + for key, val := range val.AsValueMap() { + switch key { + case "type": + if val.Type() != cty.String { + return fmt.Errorf("block type must be a string") + } + s.Type = val.AsString() + case "labels": + if val.Type() != cty.List(cty.String) { + return fmt.Errorf("block aliases must be a list of strings") + } + s.Labels = []string{} + for _, v := range val.AsValueSlice() { + if !v.IsWhollyKnown() || v.IsNull() { + continue + } + s.Labels = append(s.Labels, v.AsString()) + } + case "attrs": + if !val.CanIterateElements() { + return fmt.Errorf("provider attrs must a map of attributes") + } + + for k, v := range val.AsValueMap() { + s.Attrs[k] = v + } + case "blocks": + if !val.CanIterateElements() { + return fmt.Errorf("provider blocks must be a list of blocks") + } + + for _, v := range val.AsValueSlice() { + sb := NewSchemalessBlock() + err := sb.FromCtyValue(v) + if err != nil { + return err + } + s.Children = append(s.Children, sb) + } + default: + return fmt.Errorf("unknown key in value object: %s", key) + } + } + + return nil +} diff --git a/internal/flightplan/schemaless_block_test.go b/internal/flightplan/schemaless_block_test.go new file mode 100644 index 00000000..a975f2c9 --- /dev/null +++ b/internal/flightplan/schemaless_block_test.go @@ -0,0 +1,188 @@ +package flightplan + +import ( + "testing" + + "github.com/stretchr/testify/require" + "github.com/zclconf/go-cty/cty" + + hcl "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" +) + +func Test_SchemalessBlock_Decode(t *testing.T) { + t.Parallel() + + for _, test := range []struct { + desc string + body string + ctx *hcl.EvalContext + expected *SchemalessBlock + }{ + { + desc: "big old nested guy", + body: ` +provider "is" "anything" { + attr = "1" + + no_label { + attr = "1" + child_no_label { + attr = "2" + deep_child_no_label { + attr = boop + } + } + } + + one_label "one" { + attr = "1" + child_one_label "two" { + attr = "2" + deep_child_one_label "three" { + attr = boop + } + } + } +}`, + ctx: &hcl.EvalContext{ + Variables: map[string]cty.Value{ + "boop": cty.StringVal("beep"), + }, + }, + expected: &SchemalessBlock{ + Type: "provider", + Labels: []string{"is", "anything"}, + Attrs: map[string]cty.Value{"attr": cty.StringVal("1")}, + Children: []*SchemalessBlock{ + { + Type: "no_label", + Attrs: map[string]cty.Value{"attr": cty.StringVal("1")}, + Children: []*SchemalessBlock{ + { + Type: "child_no_label", + Attrs: map[string]cty.Value{"attr": cty.StringVal("2")}, + Children: []*SchemalessBlock{ + { + Type: "deep_child_no_label", + Attrs: map[string]cty.Value{"attr": cty.StringVal("beep")}, + Children: []*SchemalessBlock{}, + }, + }, + }, + }, + }, + { + Type: "one_label", + Labels: []string{"one"}, + Attrs: map[string]cty.Value{"attr": cty.StringVal("1")}, + Children: []*SchemalessBlock{ + { + Type: "child_one_label", + Labels: []string{"two"}, + Attrs: map[string]cty.Value{"attr": cty.StringVal("2")}, + Children: []*SchemalessBlock{ + { + Type: "deep_child_one_label", + Labels: []string{"three"}, + Attrs: map[string]cty.Value{"attr": cty.StringVal("beep")}, + Children: []*SchemalessBlock{}, + }, + }, + }, + }, + }, + }, + }, + }, + } { + t.Run(test.desc, func(t *testing.T) { + file, diags := hclsyntax.ParseConfig([]byte(test.body), "in.hcl", hcl.InitialPos) + if diags.HasErrors() { + t.Fatal(diags.Error()) + } + files := map[string]*hcl.File{"in.hcl": file} + + body, ok := file.Body.(*hclsyntax.Body) + require.True(t, ok) + + csb := NewSchemalessBlock() + diags = csb.Decode(body.Blocks[0].AsHCLBlock(), test.ctx) + require.Len(t, diags, 0, testDiagsToError(files, diags)) + require.EqualValues(t, test.expected, csb) + }) + } +} + +func Test_SchemalessBlock_Roundtrip(t *testing.T) { + t.Parallel() + + for _, test := range []struct { + desc string + ctx *hcl.EvalContext + expected *SchemalessBlock + }{ + { + desc: "big old nested guy", + ctx: &hcl.EvalContext{ + Variables: map[string]cty.Value{ + "boop": cty.StringVal("beep"), + }, + }, + expected: &SchemalessBlock{ + Type: "provider", + Labels: []string{"is", "anything"}, + Attrs: map[string]cty.Value{"attr": cty.StringVal("1")}, + Children: []*SchemalessBlock{ + { + Type: "no_label", + Labels: []string{}, + Attrs: map[string]cty.Value{"attr": cty.StringVal("1")}, + Children: []*SchemalessBlock{ + { + Type: "child_no_label", + Labels: []string{}, + Attrs: map[string]cty.Value{"attr": cty.StringVal("2")}, + Children: []*SchemalessBlock{ + { + Type: "deep_child_no_label", + Labels: []string{}, + Attrs: map[string]cty.Value{"attr": cty.StringVal("boop")}, + Children: []*SchemalessBlock{}, + }, + }, + }, + }, + }, + { + Type: "one_label", + Labels: []string{"one"}, + Attrs: map[string]cty.Value{"attr": cty.StringVal("1")}, + Children: []*SchemalessBlock{ + { + Type: "child_one_label", + Labels: []string{"two"}, + Attrs: map[string]cty.Value{"attr": cty.StringVal("2")}, + Children: []*SchemalessBlock{ + { + Type: "deep_child_one_label", + Labels: []string{"three"}, + Attrs: map[string]cty.Value{"attr": cty.StringVal("boop")}, + Children: []*SchemalessBlock{}, + }, + }, + }, + }, + }, + }, + }, + }, + } { + t.Run(test.desc, func(t *testing.T) { + val := test.expected.ToCtyValue() + got := NewSchemalessBlock() + require.NoError(t, got.FromCtyValue(val)) + require.EqualValues(t, test.expected, got) + }) + } +} diff --git a/internal/generate/generate.go b/internal/generate/generate.go index a4f48ac4..20f56da2 100644 --- a/internal/generate/generate.go +++ b/internal/generate/generate.go @@ -387,22 +387,45 @@ func (g *Generator) maybeWriteTerraformSettings(rootBody *hclwrite.Body) { rootBody.AppendNewline() } +func (g *Generator) writeSchemalessBlocks( + body *hclwrite.Body, + blocks []*flightplan.SchemalessBlock, +) { + for count, block := range blocks { + if count > 0 { + body.AppendNewline() + } + hclBlock := body.AppendNewBlock(block.Type, block.Labels) + blockBody := hclBlock.Body() + + for name, val := range block.Attrs { + blockBody.SetAttributeValue(name, val) + } + + if len(block.Children) > 0 { + g.writeSchemalessBlocks(blockBody, block.Children) + } + } +} + func (g *Generator) maybeWriteProviderConfig(rootBody *hclwrite.Body) { if len(g.Scenario.Providers) == 0 { return } - count := 0 - for _, provider := range g.Scenario.Providers { - if count > 0 { + for i, provider := range g.Scenario.Providers { + if i > 0 { rootBody.AppendNewline() } - count++ block := rootBody.AppendNewBlock("provider", []string{provider.Type}) body := block.Body() - for name, val := range provider.Attrs { + + for name, val := range provider.Config.Attrs { body.SetAttributeValue(name, val) } + + g.writeSchemalessBlocks(body, provider.Config.Children) + // Don't alias "default" providers to ensure that step modules inherit // their configuration by default. if provider.Alias != "" && provider.Alias != "default" {