diff --git a/internal/backend/local/backend_plan.go b/internal/backend/local/backend_plan.go index 3937a4948b..f421347163 100644 --- a/internal/backend/local/backend_plan.go +++ b/internal/backend/local/backend_plan.go @@ -242,6 +242,21 @@ func maybeWriteGeneratedConfig(plan *plans.Plan, out string) (wroteConfig bool, return false, diags.Append(moreDiags) } } + + // When running a list operation, the results are stored as queries and the + // resource changes above are not populated. + for _, q := range plan.Changes.Queries { + change := genconfig.Change{ + Addr: q.Addr.String(), + GeneratedConfig: q.Generated.String(), + } + + var moreDiags tfdiags.Diagnostics + writer, _, moreDiags = change.MaybeWriteConfig(writer, out) + if moreDiags.HasErrors() { + return false, diags.Append(moreDiags) + } + } } if wroteConfig { diff --git a/internal/command/views/hook_json.go b/internal/command/views/hook_json.go index 081036ab18..7d94796815 100644 --- a/internal/command/views/hook_json.go +++ b/internal/command/views/hook_json.go @@ -247,10 +247,15 @@ func (h *jsonHook) PreListQuery(id terraform.HookResourceIdentity, input_config func (h *jsonHook) PostListQuery(id terraform.HookResourceIdentity, results plans.QueryResults) (terraform.HookAction, error) { addr := id.Addr data := results.Value.GetAttr("data") - for it := data.ElementIterator(); it.Next(); { - _, value := it.Element() + iter := data.ElementIterator() + for idx := 0; iter.Next(); idx++ { + _, value := iter.Element() - result := json.NewQueryResult(addr, value) + generated := results.Generated + if generated != nil { + generated = generated.Results[idx] + } + result := json.NewQueryResult(addr, value, generated) h.view.log.Info( fmt.Sprintf("%s: Result found", addr.String()), diff --git a/internal/command/views/json/query.go b/internal/command/views/json/query.go index 27aa73c51a..1391fc4ebb 100644 --- a/internal/command/views/json/query.go +++ b/internal/command/views/json/query.go @@ -7,6 +7,7 @@ import ( "encoding/json" "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/genconfig" "github.com/zclconf/go-cty/cty" ctyjson "github.com/zclconf/go-cty/cty/json" ) @@ -24,6 +25,7 @@ type QueryResult struct { ResourceType string `json:"resource_type"` ResourceObject map[string]json.RawMessage `json:"resource_object,omitempty"` Config string `json:"config,omitempty"` + ImportConfig string `json:"import_config,omitempty"` } func NewQueryStart(addr addrs.AbsResourceInstance, input_config cty.Value) QueryStart { @@ -34,15 +36,22 @@ func NewQueryStart(addr addrs.AbsResourceInstance, input_config cty.Value) Query } } -func NewQueryResult(addr addrs.AbsResourceInstance, value cty.Value) QueryResult { - return QueryResult{ - Address: addr.String(), +func NewQueryResult(listAddr addrs.AbsResourceInstance, value cty.Value, generated *genconfig.Resource) QueryResult { + var config, importConfig string + if generated != nil { + config = generated.String() + importConfig = string(generated.Import) + } + result := QueryResult{ + Address: listAddr.String(), DisplayName: value.GetAttr("display_name").AsString(), Identity: marshalValues(value.GetAttr("identity")), - ResourceType: addr.Resource.Resource.Type, + ResourceType: listAddr.Resource.Resource.Type, ResourceObject: marshalValues(value.GetAttr("state")), - // TODO: Add config once we have it available + Config: config, + ImportConfig: importConfig, } + return result } func marshalValues(value cty.Value) map[string]json.RawMessage { diff --git a/internal/genconfig/generate_config.go b/internal/genconfig/generate_config.go index 1faf9939e7..4db2f60c06 100644 --- a/internal/genconfig/generate_config.go +++ b/internal/genconfig/generate_config.go @@ -4,6 +4,7 @@ package genconfig import ( + "bytes" "encoding/json" "fmt" "maps" @@ -21,6 +22,50 @@ import ( "github.com/hashicorp/terraform/internal/tfdiags" ) +type Resource struct { + // HCL Body of the resource, which is the attributes and blocks + // that are part of the resource. + Body []byte + + // Import is the HCL code for the import block. This is only + // generated for list resource results. + Import []byte + Addr addrs.AbsResourceInstance + Results []*Resource +} + +func (r *Resource) String() string { + var buf strings.Builder + switch r.Addr.Resource.Resource.Mode { + case addrs.ListResourceMode: + last := len(r.Results) - 1 + // sort the results by their keys so the output is consistent + for idx, managed := range r.Results { + if managed.Body != nil { + buf.WriteString(managed.String()) + buf.WriteString("\n") + } + if managed.Import != nil { + buf.WriteString(string(managed.Import)) + buf.WriteString("\n") + } + if idx != last { + buf.WriteString("\n") + } + } + case addrs.ManagedResourceMode: + buf.WriteString(fmt.Sprintf("resource %q %q {\n", r.Addr.Resource.Resource.Type, r.Addr.Resource.Resource.Name)) + buf.Write(r.Body) + buf.WriteString("}") + default: + panic(fmt.Errorf("unsupported resource mode %s", r.Addr.Resource.Resource.Mode)) + } + + // The output better be valid HCL which can be parsed and formatted. + formatted := hclwrite.Format([]byte(buf.String())) + return string(formatted) +} + // GenerateResourceContents generates HCL configuration code for the provided // resource and state value. // @@ -30,7 +75,7 @@ import ( func GenerateResourceContents(addr addrs.AbsResourceInstance, schema *configschema.Block, pc addrs.LocalProviderConfig, - stateVal cty.Value) (string, tfdiags.Diagnostics) { + stateVal cty.Value) (*Resource, tfdiags.Diagnostics) { var buf strings.Builder var diags tfdiags.Diagnostics @@ -44,25 +89,97 @@ func GenerateResourceContents(addr addrs.AbsResourceInstance, diags = diags.Append(writeConfigAttributes(addr, &buf, schema.Attributes, 2)) diags = diags.Append(writeConfigBlocks(addr, &buf, schema.BlockTypes, 2)) } else { - diags = diags.Append(writeConfigAttributesFromExisting(addr, &buf, stateVal, schema.Attributes, 2)) + diags = diags.Append(writeConfigAttributesFromExisting(addr, &buf, stateVal, schema.Attributes, 2, optionalOrRequiredProcessor)) diags = diags.Append(writeConfigBlocksFromExisting(addr, &buf, stateVal, schema.BlockTypes, 2)) } // The output better be valid HCL which can be parsed and formatted. formatted := hclwrite.Format([]byte(buf.String())) - return string(formatted), diags + return &Resource{ + Body: formatted, + Addr: addr, + }, diags } -func WrapResourceContents(addr addrs.AbsResourceInstance, config string) string { +func GenerateListResourceContents(addr addrs.AbsResourceInstance, + schema *configschema.Block, + idSchema *configschema.Object, + pc addrs.LocalProviderConfig, + stateVal cty.Value, +) (*Resource, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + if !stateVal.CanIterateElements() { + diags = diags.Append( + hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid resource instance value", + Detail: fmt.Sprintf("Resource instance %s has nil or non-iterable value", addr), + }) + return nil, diags + } + + ret := make([]*Resource, stateVal.LengthInt()) + iter := stateVal.ElementIterator() + for idx := 0; iter.Next(); idx++ { + // Generate a unique resource name for each instance in the list. + resAddr := addrs.AbsResourceInstance{ + Module: addr.Module, + Resource: addrs.ResourceInstance{ + Resource: addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: addr.Resource.Resource.Type, + Name: fmt.Sprintf("%s_%d", addr.Resource.Resource.Name, idx), + }, + Key: addr.Resource.Key, + }, + } + ls := &Resource{Addr: resAddr} + ret[idx] = ls + + _, val := iter.Element() + // we still need to generate the resource block even if the state is not given, + // so that the import block can reference it. + stateVal := cty.NilVal + if val.Type().HasAttribute("state") { + stateVal = val.GetAttr("state") + } + content, gDiags := GenerateResourceContents(resAddr, schema, pc, stateVal) + if gDiags.HasErrors() { + diags = diags.Append(gDiags) + continue + } + ls.Body = content.Body + + idVal := val.GetAttr("identity") + importContent, gDiags := generateImportBlock(resAddr, idSchema, pc, idVal) + if gDiags.HasErrors() { + diags = diags.Append(gDiags) + continue + } + ls.Import = bytes.TrimSpace(hclwrite.Format([]byte(importContent))) + } + + return &Resource{ + Results: ret, + Addr: addr, + }, diags +} + +func generateImportBlock(addr addrs.AbsResourceInstance, idSchema *configschema.Object, pc addrs.LocalProviderConfig, identity cty.Value) (string, tfdiags.Diagnostics) { var buf strings.Builder + var diags tfdiags.Diagnostics - buf.WriteString(fmt.Sprintf("resource %q %q {\n", addr.Resource.Resource.Type, addr.Resource.Resource.Name)) - buf.WriteString(config) - buf.WriteString("}") + buf.WriteString("\n") + buf.WriteString("import {\n") + buf.WriteString(fmt.Sprintf(" to = %s\n", addr.String())) + buf.WriteString(fmt.Sprintf(" provider = %s\n", pc.StringCompact())) + buf.WriteString(" identity = {\n") + diags = diags.Append(writeConfigAttributesFromExisting(addr, &buf, identity, idSchema.Attributes, 2, allowAllAttributesProcessor)) + buf.WriteString(strings.Repeat(" ", 2)) + buf.WriteString("}\n}\n") - // The output better be valid HCL which can be parsed and formatted. formatted := hclwrite.Format([]byte(buf.String())) - return string(formatted) + return string(formatted), diags } func writeConfigAttributes(addr addrs.AbsResourceInstance, buf *strings.Builder, attrs map[string]*configschema.Attribute, indent int) tfdiags.Diagnostics { @@ -112,7 +229,16 @@ func writeConfigAttributes(addr addrs.AbsResourceInstance, buf *strings.Builder, return diags } -func writeConfigAttributesFromExisting(addr addrs.AbsResourceInstance, buf *strings.Builder, stateVal cty.Value, attrs map[string]*configschema.Attribute, indent int) tfdiags.Diagnostics { +func optionalOrRequiredProcessor(attr *configschema.Attribute) bool { + // Exclude computed-only attributes + return attr.Optional || attr.Required +} + +func allowAllAttributesProcessor(attr *configschema.Attribute) bool { + return true +} + +func writeConfigAttributesFromExisting(addr addrs.AbsResourceInstance, buf *strings.Builder, stateVal cty.Value, attrs map[string]*configschema.Attribute, indent int, processAttr func(*configschema.Attribute) bool) tfdiags.Diagnostics { var diags tfdiags.Diagnostics if len(attrs) == 0 { return diags @@ -126,8 +252,7 @@ func writeConfigAttributesFromExisting(addr addrs.AbsResourceInstance, buf *stri continue } - // Exclude computed-only attributes - if attrS.Required || attrS.Optional { + if processAttr != nil && processAttr(attrS) { buf.WriteString(strings.Repeat(" ", indent)) buf.WriteString(fmt.Sprintf("%s = ", name)) @@ -327,6 +452,7 @@ func writeConfigBlocksFromExisting(addr addrs.AbsResourceInstance, buf *strings. func writeConfigNestedTypeAttributeFromExisting(addr addrs.AbsResourceInstance, buf *strings.Builder, name string, schema *configschema.Attribute, stateVal cty.Value, indent int) tfdiags.Diagnostics { var diags tfdiags.Diagnostics + processor := optionalOrRequiredProcessor switch schema.NestedType.Nesting { case configschema.NestingSingle: @@ -354,7 +480,7 @@ func writeConfigNestedTypeAttributeFromExisting(addr addrs.AbsResourceInstance, buf.WriteString(strings.Repeat(" ", indent)) buf.WriteString(fmt.Sprintf("%s = {\n", name)) - diags = diags.Append(writeConfigAttributesFromExisting(addr, buf, nestedVal, schema.NestedType.Attributes, indent+2)) + diags = diags.Append(writeConfigAttributesFromExisting(addr, buf, nestedVal, schema.NestedType.Attributes, indent+2, processor)) buf.WriteString("}\n") return diags @@ -386,7 +512,7 @@ func writeConfigNestedTypeAttributeFromExisting(addr addrs.AbsResourceInstance, } buf.WriteString("{\n") - diags = diags.Append(writeConfigAttributesFromExisting(addr, buf, listVals[i], schema.NestedType.Attributes, indent+4)) + diags = diags.Append(writeConfigAttributesFromExisting(addr, buf, listVals[i], schema.NestedType.Attributes, indent+4, processor)) buf.WriteString(strings.Repeat(" ", indent+2)) buf.WriteString("},\n") } @@ -424,7 +550,7 @@ func writeConfigNestedTypeAttributeFromExisting(addr addrs.AbsResourceInstance, } buf.WriteString("\n") - diags = diags.Append(writeConfigAttributesFromExisting(addr, buf, vals[key], schema.NestedType.Attributes, indent+4)) + diags = diags.Append(writeConfigAttributesFromExisting(addr, buf, vals[key], schema.NestedType.Attributes, indent+4, processor)) buf.WriteString(strings.Repeat(" ", indent+2)) buf.WriteString("}\n") } @@ -440,6 +566,7 @@ func writeConfigNestedTypeAttributeFromExisting(addr addrs.AbsResourceInstance, func writeConfigNestedBlockFromExisting(addr addrs.AbsResourceInstance, buf *strings.Builder, name string, schema *configschema.NestedBlock, stateVal cty.Value, indent int) tfdiags.Diagnostics { var diags tfdiags.Diagnostics + processAttr := optionalOrRequiredProcessor switch schema.Nesting { case configschema.NestingSingle, configschema.NestingGroup: @@ -455,7 +582,7 @@ func writeConfigNestedBlockFromExisting(addr addrs.AbsResourceInstance, buf *str return diags } buf.WriteString("\n") - diags = diags.Append(writeConfigAttributesFromExisting(addr, buf, stateVal, schema.Attributes, indent+2)) + diags = diags.Append(writeConfigAttributesFromExisting(addr, buf, stateVal, schema.Attributes, indent+2, processAttr)) diags = diags.Append(writeConfigBlocksFromExisting(addr, buf, stateVal, schema.BlockTypes, indent+2)) buf.WriteString("}\n") return diags @@ -469,7 +596,7 @@ func writeConfigNestedBlockFromExisting(addr addrs.AbsResourceInstance, buf *str for i := range listVals { buf.WriteString(strings.Repeat(" ", indent)) buf.WriteString(fmt.Sprintf("%s {\n", name)) - diags = diags.Append(writeConfigAttributesFromExisting(addr, buf, listVals[i], schema.Attributes, indent+2)) + diags = diags.Append(writeConfigAttributesFromExisting(addr, buf, listVals[i], schema.Attributes, indent+2, processAttr)) diags = diags.Append(writeConfigBlocksFromExisting(addr, buf, listVals[i], schema.BlockTypes, indent+2)) buf.WriteString("}\n") } @@ -491,7 +618,7 @@ func writeConfigNestedBlockFromExisting(addr addrs.AbsResourceInstance, buf *str return diags } buf.WriteString("\n") - diags = diags.Append(writeConfigAttributesFromExisting(addr, buf, vals[key], schema.Attributes, indent+2)) + diags = diags.Append(writeConfigAttributesFromExisting(addr, buf, vals[key], schema.Attributes, indent+2, processAttr)) diags = diags.Append(writeConfigBlocksFromExisting(addr, buf, vals[key], schema.BlockTypes, indent+2)) buf.WriteString(strings.Repeat(" ", indent)) buf.WriteString("}\n") diff --git a/internal/genconfig/generate_config_test.go b/internal/genconfig/generate_config_test.go index 87d3fb778f..86a5634f5d 100644 --- a/internal/genconfig/generate_config_test.go +++ b/internal/genconfig/generate_config_test.go @@ -830,7 +830,7 @@ resource "tfcoremock_sensitive_values" "values" { t.Errorf("expected no diagnostics but found %s", diags) } - got := WrapResourceContents(tc.addr, contents) + got := contents.String() want := strings.TrimSpace(tc.expected) if diff := cmp.Diff(got, want); len(diff) > 0 { t.Errorf("got:\n%s\nwant:\n%s\ndiff:\n%s", got, want, diff) @@ -846,3 +846,179 @@ func sensitiveAttribute(t cty.Type) *configschema.Attribute { Sensitive: true, } } + +func TestGenerateResourceAndIDContents(t *testing.T) { + schema := &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "name": { + Type: cty.String, + Optional: true, + }, + "id": { + Type: cty.String, + Computed: true, + }, + "tags": { + Type: cty.Map(cty.String), + Optional: true, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "network_interface": { + Nesting: configschema.NestingList, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "subnet_id": { + Type: cty.String, + Required: true, + }, + "ip_address": { + Type: cty.String, + Optional: true, + }, + }, + }, + }, + }, + } + + // Define the identity schema + idSchema := &configschema.Object{ + Nesting: configschema.NestingSingle, + Attributes: map[string]*configschema.Attribute{ + "id": { + Type: cty.String, + Optional: true, + }, + }, + } + + // Create mock resource instance values + value := cty.TupleVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "state": cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("instance-1"), + "id": cty.StringVal("i-abcdef"), + "tags": cty.MapVal(map[string]cty.Value{ + "Environment": cty.StringVal("Dev"), + "Owner": cty.StringVal("Team1"), + }), + "network_interface": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "subnet_id": cty.StringVal("subnet-123"), + "ip_address": cty.StringVal("10.0.0.1"), + }), + }), + }), + "identity": cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("i-abcdef"), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "state": cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("instance-2"), + "id": cty.StringVal("i-123456"), + "tags": cty.MapVal(map[string]cty.Value{ + "Environment": cty.StringVal("Prod"), + "Owner": cty.StringVal("Team2"), + }), + "network_interface": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "subnet_id": cty.StringVal("subnet-456"), + "ip_address": cty.StringVal("10.0.0.2"), + }), + }), + }), + "identity": cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("i-123456"), + }), + }), + }) + + // Create test resource address + addr := addrs.AbsResource{ + Module: addrs.RootModuleInstance, + Resource: addrs.Resource{ + Mode: addrs.ListResourceMode, + Type: "aws_instance", + Name: "example", + }, + } + + // Create instance addresses for each instance + instAddr1 := addr.Instance(addrs.NoKey) + + // Create provider config + pc := addrs.LocalProviderConfig{ + LocalName: "aws", + } + + // Generate content + content, diags := GenerateListResourceContents(instAddr1, schema, idSchema, pc, value) + // Check for diagnostics + if diags.HasErrors() { + t.Fatalf("unexpected diagnostics: %s", diags.Err()) + } + + // Check the generated content + expectedContent := `resource "aws_instance" "example_0" { + name = "instance-1" + tags = { + Environment = "Dev" + Owner = "Team1" + } + network_interface { + ip_address = "10.0.0.1" + subnet_id = "subnet-123" + } +} +import { + to = aws_instance.example_0 + provider = aws + identity = { + id = "i-abcdef" + } +} + +resource "aws_instance" "example_1" { + name = "instance-2" + tags = { + Environment = "Prod" + Owner = "Team2" + } + network_interface { + ip_address = "10.0.0.2" + subnet_id = "subnet-456" + } +} +import { + to = aws_instance.example_1 + provider = aws + identity = { + id = "i-123456" + } +} +` + // Normalize both strings by removing extra whitespace for comparison + normalizeString := func(s string) string { + // Remove spaces at the end of lines and replace multiple newlines with a single one + lines := strings.Split(s, "\n") + for i, line := range lines { + lines[i] = strings.TrimRight(line, " \t") + } + return strings.Join(lines, "\n") + } + + normalizedExpected := normalizeString(expectedContent) + + var merged string + res := content.Results + for _, addr := range res { + merged += addr.String() + } + normalizedActual := normalizeString(content.String()) + + if diff := cmp.Diff(normalizedExpected, normalizedActual); diff != "" { + t.Errorf("Generated content doesn't match expected. want:\n%s\ngot:\n%s\ndiff:\n%s", normalizedExpected, normalizedActual, diff) + } +} diff --git a/internal/genconfig/generate_config_write.go b/internal/genconfig/generate_config_write.go index 870086aa1f..8bcf1af202 100644 --- a/internal/genconfig/generate_config_write.go +++ b/internal/genconfig/generate_config_write.go @@ -69,7 +69,7 @@ func (c *Change) MaybeWriteConfig(writer io.Writer, out string) (io.Writer, bool header += fmt.Sprintf(" from %q", c.ImportID) } header += "\n" - if _, err := writer.Write([]byte(fmt.Sprintf("%s%s\n", header, c.GeneratedConfig))); err != nil { + if _, err := writer.Write(fmt.Appendf(nil, "%s%s\n", header, c.GeneratedConfig)); err != nil { diags = diags.Append(tfdiags.Sourceless( tfdiags.Warning, "Failed to save generated config", diff --git a/internal/plans/changes.go b/internal/plans/changes.go index e2cb7dd187..5ed41122ac 100644 --- a/internal/plans/changes.go +++ b/internal/plans/changes.go @@ -9,6 +9,7 @@ import ( "github.com/zclconf/go-cty/cty" "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/genconfig" "github.com/hashicorp/terraform/internal/lang/marks" "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/schemarepo" @@ -251,7 +252,8 @@ type QueryInstance struct { } type QueryResults struct { - Value cty.Value + Value cty.Value + Generated *genconfig.Resource } func (qi *QueryInstance) DeepCopy() *QueryInstance { @@ -273,6 +275,7 @@ func (rc *QueryInstance) Encode(schema providers.Schema) (*QueryInstanceSrc, err Addr: rc.Addr, Results: results, ProviderAddr: rc.ProviderAddr, + Generated: rc.Results.Generated, }, nil } diff --git a/internal/plans/changes_src.go b/internal/plans/changes_src.go index 023dca14ec..8df9f78640 100644 --- a/internal/plans/changes_src.go +++ b/internal/plans/changes_src.go @@ -9,6 +9,7 @@ import ( "github.com/zclconf/go-cty/cty" "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/genconfig" "github.com/hashicorp/terraform/internal/lang/marks" "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/schemarepo" @@ -176,11 +177,10 @@ func (c *ChangesSrc) AppendResourceInstanceChange(change *ResourceInstanceChange } type QueryInstanceSrc struct { - Addr addrs.AbsResourceInstance - + Addr addrs.AbsResourceInstance ProviderAddr addrs.AbsProviderConfig - - Results DynamicValue + Results DynamicValue + Generated *genconfig.Resource } func (qis *QueryInstanceSrc) Decode(schema providers.Schema) (*QueryInstance, error) { @@ -192,7 +192,8 @@ func (qis *QueryInstanceSrc) Decode(schema providers.Schema) (*QueryInstance, er return &QueryInstance{ Addr: qis.Addr, Results: QueryResults{ - Value: query, + Value: query, + Generated: qis.Generated, }, ProviderAddr: qis.ProviderAddr, }, nil diff --git a/internal/terraform/context_plan_query_test.go b/internal/terraform/context_plan_query_test.go index 5d6fc6f237..ecbcc745ac 100644 --- a/internal/terraform/context_plan_query_test.go +++ b/internal/terraform/context_plan_query_test.go @@ -5,6 +5,7 @@ package terraform import ( "fmt" + "maps" "sort" "strings" "testing" @@ -22,10 +23,12 @@ import ( ) func TestContext2Plan_queryList(t *testing.T) { + cases := []struct { name string mainConfig string queryConfig string + generatedPath string diagCount int expectedErrMsg []string assertState func(*states.State) @@ -33,7 +36,7 @@ func TestContext2Plan_queryList(t *testing.T) { listResourceFn func(request providers.ListResourceRequest) providers.ListResourceResponse }{ { - name: "valid list reference", + name: "valid list reference - generates config", mainConfig: ` terraform { required_providers { @@ -71,6 +74,7 @@ func TestContext2Plan_queryList(t *testing.T) { } } `, + generatedPath: t.TempDir(), listResourceFn: func(request providers.ListResourceRequest) providers.ListResourceResponse { madeUp := []cty.Value{ cty.ObjectVal(map[string]cty.Value{"instance_type": cty.StringVal("ami-123456")}), @@ -85,68 +89,62 @@ func TestContext2Plan_queryList(t *testing.T) { } resp := []cty.Value{} - if request.IncludeResourceObject { - for i, v := range madeUp { - resp = append(resp, cty.ObjectVal(map[string]cty.Value{ - "state": v, - "identity": ids[i], - "display_name": cty.StringVal(fmt.Sprintf("Instance %d", i+1)), - })) + for i, v := range madeUp { + mp := map[string]cty.Value{ + "identity": ids[i], + "display_name": cty.StringVal(fmt.Sprintf("Instance %d", i+1)), + } + if request.IncludeResourceObject { + mp["state"] = v } + resp = append(resp, cty.ObjectVal(mp)) } - ret := map[string]cty.Value{ + ret := request.Config.AsValueMap() + maps.Copy(ret, map[string]cty.Value{ "data": cty.TupleVal(resp), - } - for k, v := range request.Config.AsValueMap() { - if k != "data" { - ret[k] = v - } - } + }) return providers.ListResourceResponse{Result: cty.ObjectVal(ret)} }, assertChanges: func(sch providers.ProviderSchema, changes *plans.ChangesSrc) { - expectedResources := map[string][]string{ - "list.test_resource.test": {"ami-123456", "ami-654321", "ami-789012"}, - "list.test_resource.test2": {}, - } - actualResources := map[string][]string{} + expectedResources := []string{"list.test_resource.test", "list.test_resource.test2"} + actualResources := make([]string, 0) + generatedCfgs := make([]string, 0) for _, change := range changes.Queries { + actualResources = append(actualResources, change.Addr.String()) schema := sch.ListResourceTypes[change.Addr.Resource.Resource.Type] cs, err := change.Decode(schema) if err != nil { t.Fatalf("failed to decode change: %s", err) } - // Verify instance types - actualTypes := make([]string, 0) obj := cs.Results.Value.GetAttr("data") if obj.IsNull() { t.Fatalf("Expected 'data' attribute to be present, but it is null") } obj.ForEachElement(func(key cty.Value, val cty.Value) bool { - if !val.Type().HasAttribute("state") { - t.Fatalf("Expected 'state' attribute to be present, but it is missing") - } - - val = val.GetAttr("state") - if !val.IsNull() { - if val.GetAttr("instance_type").IsNull() { - t.Fatalf("Expected 'instance_type' attribute to be present, but it is missing") + if val.Type().HasAttribute("state") { + val = val.GetAttr("state") + if !val.IsNull() { + if val.GetAttr("instance_type").IsNull() { + t.Fatalf("Expected 'instance_type' attribute to be present, but it is missing") + } } - actualTypes = append(actualTypes, val.GetAttr("instance_type").AsString()) } return false }) - sort.Strings(actualTypes) - actualResources[change.Addr.String()] = actualTypes + generatedCfgs = append(generatedCfgs, change.Generated.String()) } if diff := cmp.Diff(expectedResources, actualResources); diff != "" { t.Fatalf("Expected resources to match, but they differ: %s", diff) } + + if diff := cmp.Diff([]string{testResourceCfg, testResourceCfg2}, generatedCfgs); diff != "" { + t.Fatalf("Expected generated configs to match, but they differ: %s", diff) + } }, }, { @@ -709,9 +707,10 @@ func TestContext2Plan_queryList(t *testing.T) { tfdiags.AssertNoDiagnostics(t, diags) plan, diags := ctx.Plan(mod, states.NewState(), &PlanOpts{ - Mode: plans.NormalMode, - SetVariables: testInputValuesUnset(mod.Module.Variables), - Query: true, + Mode: plans.NormalMode, + SetVariables: testInputValuesUnset(mod.Module.Variables), + Query: true, + GenerateConfigPath: tc.generatedPath, }) if len(diags) != tc.diagCount { t.Fatalf("expected %d diagnostics, got %d \n -diags: %s", tc.diagCount, len(diags), diags) @@ -924,6 +923,7 @@ func getListProviderSchemaResp() *providers.GetProviderSchemaResponse { "instance_type": { Type: cty.String, Computed: true, + Optional: true, }, }, }, @@ -962,3 +962,73 @@ func getListProviderSchemaResp() *providers.GetProviderSchemaResponse { }, }) } + +var ( + testResourceCfg = `resource "test_resource" "test_0" { + instance_type = "ami-123456" +} +import { + to = test_resource.test_0 + provider = test + identity = { + id = "i-v1" + } +} + +resource "test_resource" "test_1" { + instance_type = "ami-654321" +} +import { + to = test_resource.test_1 + provider = test + identity = { + id = "i-v2" + } +} + +resource "test_resource" "test_2" { + instance_type = "ami-789012" +} +import { + to = test_resource.test_2 + provider = test + identity = { + id = "i-v3" + } +} +` + + testResourceCfg2 = `resource "test_resource" "test2_0" { + instance_type = null # OPTIONAL string +} +import { + to = test_resource.test2_0 + provider = test + identity = { + id = "i-v1" + } +} + +resource "test_resource" "test2_1" { + instance_type = null # OPTIONAL string +} +import { + to = test_resource.test2_1 + provider = test + identity = { + id = "i-v2" + } +} + +resource "test_resource" "test2_2" { + instance_type = null # OPTIONAL string +} +import { + to = test_resource.test2_2 + provider = test + identity = { + id = "i-v3" + } +} +` +) diff --git a/internal/terraform/node_resource_abstract_instance.go b/internal/terraform/node_resource_abstract_instance.go index b1a4b4626a..511b2558f9 100644 --- a/internal/terraform/node_resource_abstract_instance.go +++ b/internal/terraform/node_resource_abstract_instance.go @@ -46,7 +46,7 @@ type NodeAbstractResourceInstance struct { preDestroyRefresh bool - // During import we may generate configuration for a resource, which needs + // During import (or query) we may generate configuration for a resource, which needs // to be stored in the final change. generatedConfigHCL string diff --git a/internal/terraform/node_resource_plan_instance.go b/internal/terraform/node_resource_plan_instance.go index 2f38df5fa1..930d87ae39 100644 --- a/internal/terraform/node_resource_plan_instance.go +++ b/internal/terraform/node_resource_plan_instance.go @@ -839,16 +839,15 @@ func (n *NodePlannableResourceInstance) importState(ctx EvalContext, addr addrs. } // Generate the HCL string first, then parse the HCL body from it. - // First we generate the contents of the resource block for use within - // the planning node. Then we wrap it in an enclosing resource block to - // pass into the plan for rendering. - generatedHCLAttributes, generatedDiags := n.generateHCLStringAttributes(n.Addr, instanceRefreshState, schema.Body) + generatedResource, generatedDiags := n.generateHCLResourceDef(n.Addr, instanceRefreshState.Value, schema) diags = diags.Append(generatedDiags) - n.generatedConfigHCL = genconfig.WrapResourceContents(n.Addr, generatedHCLAttributes) + // This wraps the content of the resource block in an enclosing resource block + // to pass into the plan for rendering. + n.generatedConfigHCL = generatedResource.String() - // parse the "file" as HCL to get the hcl.Body - synthHCLFile, hclDiags := hclsyntax.ParseConfig([]byte(generatedHCLAttributes), filepath.Base(n.generateConfigPath), hcl.Pos{Byte: 0, Line: 1, Column: 1}) + // parse the "file" body as HCL to get the hcl.Body + synthHCLFile, hclDiags := hclsyntax.ParseConfig(generatedResource.Body, filepath.Base(n.generateConfigPath), hcl.Pos{Byte: 0, Line: 1, Column: 1}) diags = diags.Append(hclDiags) if hclDiags.HasErrors() { return instanceRefreshState, nil, diags @@ -883,10 +882,11 @@ func (n *NodePlannableResourceInstance) importState(ctx EvalContext, addr addrs. return instanceRefreshState, deferred, diags } -// generateHCLStringAttributes produces a string in HCL format for the given -// resource state and schema without the surrounding block. -func (n *NodePlannableResourceInstance) generateHCLStringAttributes(addr addrs.AbsResourceInstance, state *states.ResourceInstanceObject, schema *configschema.Block) (string, tfdiags.Diagnostics) { - filteredSchema := schema.Filter( +// generateHCLResourceDef generates the HCL definition for the resource +// instance, including the surrounding block. This is used to generate the +// configuration for the resource instance when importing or generating +func (n *NodePlannableResourceInstance) generateHCLResourceDef(addr addrs.AbsResourceInstance, state cty.Value, schema providers.Schema) (*genconfig.Resource, tfdiags.Diagnostics) { + filteredSchema := schema.Body.Filter( configschema.FilterOr( configschema.FilterReadOnlyAttribute, configschema.FilterDeprecatedAttribute, @@ -911,7 +911,15 @@ func (n *NodePlannableResourceInstance) generateHCLStringAttributes(addr addrs.A Alias: n.ResolvedProvider.Alias, } - return genconfig.GenerateResourceContents(addr, filteredSchema, providerAddr, state.Value) + switch addr.Resource.Resource.Mode { + case addrs.ManagedResourceMode: + return genconfig.GenerateResourceContents(addr, filteredSchema, providerAddr, state) + case addrs.ListResourceMode: + identitySchema := schema.Identity + return genconfig.GenerateListResourceContents(addr, filteredSchema, identitySchema, providerAddr, state) + default: + panic(fmt.Sprintf("unexpected resource mode %s for resource %s", addr.Resource.Resource.Mode, addr)) + } } // mergeDeps returns the union of 2 sets of dependencies diff --git a/internal/terraform/node_resource_plan_instance_query.go b/internal/terraform/node_resource_plan_instance_query.go index e390b6dfa5..b5e8836ab1 100644 --- a/internal/terraform/node_resource_plan_instance_query.go +++ b/internal/terraform/node_resource_plan_instance_query.go @@ -95,6 +95,17 @@ func (n *NodePlannableResourceInstance) listResourceExecute(ctx EvalContext) (di results := plans.QueryResults{ Value: resp.Result, } + + // If a path is specified, generate the config for the resource + if n.generateConfigPath != "" { + var gDiags tfdiags.Diagnostics + results.Generated, gDiags = n.generateHCLResourceDef(addr, resp.Result.GetAttr("data"), providerSchema.ResourceTypes[n.Config.Type]) + diags = diags.Append(gDiags) + if diags.HasErrors() { + return diags + } + } + ctx.Hook(func(h Hook) (HookAction, error) { return h.PostListQuery(rId, results) }) diff --git a/internal/terraform/transform_config.go b/internal/terraform/transform_config.go index 735cc0ecf1..df75c7c02b 100644 --- a/internal/terraform/transform_config.go +++ b/internal/terraform/transform_config.go @@ -181,6 +181,10 @@ func (t *ConfigTransformer) transformSingle(g *Graph, config *configs.Config) er importTargets: imports, } + if r.List != nil { + abstract.generateConfigPath = t.generateConfigPathForImportTargets + } + var node dag.Vertex = abstract if f := t.Concrete; f != nil { node = f(abstract)