diff --git a/internal/command/query_test.go b/internal/command/query_test.go index e7c6aaf4f9..c95b91c5c4 100644 --- a/internal/command/query_test.go +++ b/internal/command/query_test.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "path" + "regexp" "slices" "strings" "testing" @@ -333,6 +334,7 @@ func queryFixtureProvider() *testing_provider.MockProvider { }, Nesting: configschema.NestingSingle, }, + IdentityVersion: 1, }, "test_database": { Body: &configschema.Block{ @@ -476,7 +478,8 @@ func TestQuery_JSON(t *testing.T) { "identity": map[string]any{ "id": "test-instance-1", }, - "resource_type": "test_instance", + "identity_version": float64(1), + "resource_type": "test_instance", "resource_object": map[string]any{ "ami": "ami-12345", "id": "test-instance-1", @@ -493,7 +496,8 @@ func TestQuery_JSON(t *testing.T) { "identity": map[string]any{ "id": "test-instance-2", }, - "resource_type": "test_instance", + "identity_version": float64(1), + "resource_type": "test_instance", "resource_object": map[string]any{ "ami": "ami-67890", "id": "test-instance-2", @@ -536,7 +540,8 @@ func TestQuery_JSON(t *testing.T) { "identity": map[string]any{ "id": "test-instance-1", }, - "resource_type": "test_instance", + "identity_version": float64(1), + "resource_type": "test_instance", "resource_object": map[string]any{ "ami": "ami-12345", "id": "test-instance-1", @@ -555,7 +560,8 @@ func TestQuery_JSON(t *testing.T) { "identity": map[string]any{ "id": "test-instance-2", }, - "resource_type": "test_instance", + "identity_version": float64(1), + "resource_type": "test_instance", "resource_object": map[string]any{ "ami": "ami-67890", "id": "test-instance-2", @@ -651,7 +657,8 @@ func TestQuery_JSON(t *testing.T) { "identity": map[string]any{ "id": "test-instance-1", }, - "resource_type": "test_instance", + "identity_version": float64(1), + "resource_type": "test_instance", "resource_object": map[string]any{ "ami": "ami-12345", "id": "test-instance-1", @@ -668,7 +675,8 @@ func TestQuery_JSON(t *testing.T) { "identity": map[string]any{ "id": "test-instance-2", }, - "resource_type": "test_instance", + "identity_version": float64(1), + "resource_type": "test_instance", "resource_object": map[string]any{ "ami": "ami-67890", "id": "test-instance-2", @@ -771,3 +779,94 @@ func TestQuery_JSON(t *testing.T) { }) } } + +func TestQuery_JSON_Raw(t *testing.T) { + + tests := []struct { + name string + directory string + expectedOut string + expectedErr []string + initCode int + args []string + }{ + { + name: "basic query", + directory: "basic", + expectedOut: `{"@level":"info","@message":"Terraform 1.14.0-dev","@module":"terraform.ui","@timestamp":"2025-09-12T16:52:57.596469+02:00","terraform":"1.14.0-dev","type":"version","ui":"1.2"} +{"@level":"info","@message":"list.test_instance.example: Starting query...","@module":"terraform.ui","@timestamp":"2025-09-12T16:52:57.600609+02:00","list_start":{"address":"list.test_instance.example","resource_type":"test_instance","input_config":{"ami":"ami-12345","foo":null}},"type":"list_start"} +{"@level":"info","@message":"list.test_instance.example: Result found","@module":"terraform.ui","@timestamp":"2025-09-12T16:52:57.600729+02:00","list_resource_found":{"address":"list.test_instance.example","display_name":"Test Instance 1","identity":{"id":"test-instance-1"},"identity_version":1,"resource_type":"test_instance","resource_object":{"ami":"ami-12345","id":"test-instance-1"}},"type":"list_resource_found"} +{"@level":"info","@message":"list.test_instance.example: Result found","@module":"terraform.ui","@timestamp":"2025-09-12T16:52:57.600759+02:00","list_resource_found":{"address":"list.test_instance.example","display_name":"Test Instance 2","identity":{"id":"test-instance-2"},"identity_version":1,"resource_type":"test_instance","resource_object":{"ami":"ami-67890","id":"test-instance-2"}},"type":"list_resource_found"} +{"@level":"info","@message":"list.test_instance.example: List complete","@module":"terraform.ui","@timestamp":"2025-09-12T16:52:57.600770+02:00","list_complete":{"address":"list.test_instance.example","resource_type":"test_instance","total":2},"type":"list_complete"} +`, + }, + { + name: "empty result", + directory: "empty-result", + expectedOut: `{"@level":"info","@message":"Terraform 1.14.0-dev","@module":"terraform.ui","@timestamp":"2025-09-12T16:52:57.596469+02:00","terraform":"1.14.0-dev","type":"version","ui":"1.2"} +{"@level":"info","@message":"list.test_instance.example: Starting query...","@module":"terraform.ui","@timestamp":"2025-09-12T16:52:57.600609+02:00","list_start":{"address":"list.test_instance.example","resource_type":"test_instance","input_config":{"ami":"ami-12345","foo":null}},"type":"list_start"} +{"@level":"info","@message":"list.test_instance.example: Result found","@module":"terraform.ui","@timestamp":"2025-09-12T16:52:57.600729+02:00","list_resource_found":{"address":"list.test_instance.example","display_name":"Test Instance 1","identity":{"id":"test-instance-1"},"identity_version":1,"resource_type":"test_instance","resource_object":{"ami":"ami-12345","id":"test-instance-1"}},"type":"list_resource_found"} +{"@level":"info","@message":"list.test_instance.example: Result found","@module":"terraform.ui","@timestamp":"2025-09-12T16:52:57.600759+02:00","list_resource_found":{"address":"list.test_instance.example","display_name":"Test Instance 2","identity":{"id":"test-instance-2"},"identity_version":1,"resource_type":"test_instance","resource_object":{"ami":"ami-67890","id":"test-instance-2"}},"type":"list_resource_found"} +{"@level":"info","@message":"list.test_instance.example: List complete","@module":"terraform.ui","@timestamp":"2025-09-12T16:52:57.600770+02:00","list_complete":{"address":"list.test_instance.example","resource_type":"test_instance","total":2},"type":"list_complete"} +{"@level":"info","@message":"list.test_instance.example2: Starting query...","@module":"terraform.ui","@timestamp":"2025-09-12T16:52:57.600609+02:00","list_start":{"address":"list.test_instance.example2","resource_type":"test_instance","input_config":{"ami":"ami-nonexistent","foo":"test-instance-1"}},"type":"list_start"} +{"@level":"info","@message":"list.test_instance.example2: List complete","@module":"terraform.ui","@timestamp":"2025-09-12T16:52:57.600770+02:00","list_complete":{"address":"list.test_instance.example2","resource_type":"test_instance","total":0},"type":"list_complete"} +`, + }, + } + + for _, ts := range tests { + t.Run(ts.name, func(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath(path.Join("query", ts.directory)), td) + t.Chdir(td) + providerSource, close := newMockProviderSource(t, map[string][]string{ + "hashicorp/test": {"1.0.0"}, + }) + defer close() + + p := queryFixtureProvider() + view, done := testView(t) + meta := Meta{ + testingOverrides: metaOverridesForProvider(p), + View: view, + AllowExperimentalFeatures: true, + ProviderSource: providerSource, + } + + init := &InitCommand{Meta: meta} + code := init.Run(nil) + output := done(t) + if code != 0 { + t.Fatalf("expected status code %d but got %d: %s", 0, code, output.All()) + } + + view, done = testView(t) + meta.View = view + + c := &QueryCommand{Meta: meta} + args := []string{"-no-color", "-json"} + code = c.Run(args) + output = done(t) + if code != 0 { + t.Logf("query command returned non-zero code '%d' and an error: \n\n%s", code, output.All()) + } + + // Use regex to normalize timestamps and version numbers for comparison + timestampRegex := regexp.MustCompile(`"@timestamp":"[^"]*"`) + versionRegex := regexp.MustCompile(`"terraform":"[^"]*"`) + + actualOutput := output.Stdout() + expectedOutput := ts.expectedOut + + // Replace timestamps and version numbers with placeholders + actualNormalized := timestampRegex.ReplaceAllString(actualOutput, `"@timestamp":"TIMESTAMP"`) + actualNormalized = versionRegex.ReplaceAllString(actualNormalized, `"terraform":"VERSION"`) + + expectedNormalized := timestampRegex.ReplaceAllString(expectedOutput, `"@timestamp":"TIMESTAMP"`) + expectedNormalized = versionRegex.ReplaceAllString(expectedNormalized, `"terraform":"VERSION"`) + if diff := cmp.Diff(expectedNormalized, actualNormalized); diff != "" { + t.Errorf("expected query output to match, diff: %s", diff) + } + }) + } +} diff --git a/internal/command/views/hook_json.go b/internal/command/views/hook_json.go index 834cc2396a..edcb96537f 100644 --- a/internal/command/views/hook_json.go +++ b/internal/command/views/hook_json.go @@ -245,7 +245,7 @@ func (h *jsonHook) PreListQuery(id terraform.HookResourceIdentity, input_config return terraform.HookActionContinue, nil } -func (h *jsonHook) PostListQuery(id terraform.HookResourceIdentity, results plans.QueryResults) (terraform.HookAction, error) { +func (h *jsonHook) PostListQuery(id terraform.HookResourceIdentity, results plans.QueryResults, identityVersion int64) (terraform.HookAction, error) { addr := id.Addr data := results.Value.GetAttr("data") iter := data.ElementIterator() @@ -257,7 +257,7 @@ func (h *jsonHook) PostListQuery(id terraform.HookResourceIdentity, results plan generated = &results.Generated.Imports[idx] } - result := json.NewQueryResult(addr, value, generated) + result := json.NewQueryResult(addr, value, identityVersion, generated) h.view.log.Info( fmt.Sprintf("%s: Result found", addr.String()), diff --git a/internal/command/views/hook_ui.go b/internal/command/views/hook_ui.go index 6f418749a2..bb1df3bde1 100644 --- a/internal/command/views/hook_ui.go +++ b/internal/command/views/hook_ui.go @@ -511,7 +511,7 @@ func (h *UiHook) PreListQuery(id terraform.HookResourceIdentity, input_config ct return terraform.HookActionContinue, nil } -func (h *UiHook) PostListQuery(id terraform.HookResourceIdentity, results plans.QueryResults) (terraform.HookAction, error) { +func (h *UiHook) PostListQuery(id terraform.HookResourceIdentity, results plans.QueryResults, identityVersion int64) (terraform.HookAction, error) { addr := id.Addr data := results.Value.GetAttr("data") diff --git a/internal/command/views/json/query.go b/internal/command/views/json/query.go index fd9ceae39f..7d5167a798 100644 --- a/internal/command/views/json/query.go +++ b/internal/command/views/json/query.go @@ -19,13 +19,14 @@ type QueryStart struct { } type QueryResult struct { - Address string `json:"address"` - DisplayName string `json:"display_name"` - Identity map[string]json.RawMessage `json:"identity"` - 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"` + Address string `json:"address"` + DisplayName string `json:"display_name"` + Identity map[string]json.RawMessage `json:"identity"` + IdentityVersion int64 `json:"identity_version"` + 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"` } type QueryComplete struct { @@ -34,21 +35,22 @@ type QueryComplete struct { Total int `json:"total"` } -func NewQueryStart(addr addrs.AbsResourceInstance, input_config cty.Value) QueryStart { +func NewQueryStart(addr addrs.AbsResourceInstance, inputConfig cty.Value) QueryStart { return QueryStart{ Address: addr.String(), ResourceType: addr.Resource.Resource.Type, - InputConfig: marshalValues(input_config), + InputConfig: marshalValues(inputConfig), } } -func NewQueryResult(listAddr addrs.AbsResourceInstance, value cty.Value, generated *genconfig.ResourceImport) QueryResult { +func NewQueryResult(listAddr addrs.AbsResourceInstance, value cty.Value, identityVersion int64, generated *genconfig.ResourceImport) QueryResult { result := QueryResult{ - Address: listAddr.String(), - DisplayName: value.GetAttr("display_name").AsString(), - Identity: marshalValues(value.GetAttr("identity")), - ResourceType: listAddr.Resource.Resource.Type, - ResourceObject: marshalValues(value.GetAttr("state")), + Address: listAddr.String(), + DisplayName: value.GetAttr("display_name").AsString(), + Identity: marshalValues(value.GetAttr("identity")), + IdentityVersion: identityVersion, + ResourceType: listAddr.Resource.Resource.Type, + ResourceObject: marshalValues(value.GetAttr("state")), } if generated != nil { diff --git a/internal/terraform/hook.go b/internal/terraform/hook.go index a7b8704900..47cb00c671 100644 --- a/internal/terraform/hook.go +++ b/internal/terraform/hook.go @@ -117,8 +117,8 @@ type Hook interface { // PreListQuery and PostListQuery are called during a query operation before and after // resources are queried from the provider. - PreListQuery(id HookResourceIdentity, input_config cty.Value) (HookAction, error) - PostListQuery(id HookResourceIdentity, results plans.QueryResults) (HookAction, error) + PreListQuery(id HookResourceIdentity, inputConfig cty.Value) (HookAction, error) + PostListQuery(id HookResourceIdentity, results plans.QueryResults, identityVersion int64) (HookAction, error) // StartAction, ProgressAction, and CompleteAction are called during the // lifecycle of an action invocation. @@ -236,7 +236,7 @@ func (h *NilHook) PreListQuery(id HookResourceIdentity, input_config cty.Value) return HookActionContinue, nil } -func (h *NilHook) PostListQuery(id HookResourceIdentity, results plans.QueryResults) (HookAction, error) { +func (h *NilHook) PostListQuery(id HookResourceIdentity, results plans.QueryResults, identityVersion int64) (HookAction, error) { return HookActionContinue, nil } diff --git a/internal/terraform/hook_mock.go b/internal/terraform/hook_mock.go index a0ba7da59a..cc6f5cf532 100644 --- a/internal/terraform/hook_mock.go +++ b/internal/terraform/hook_mock.go @@ -383,7 +383,7 @@ func (h *MockHook) PreListQuery(id HookResourceIdentity, input_config cty.Value) return h.PreListQueryReturn, h.PreListQueryReturnError } -func (h *MockHook) PostListQuery(id HookResourceIdentity, results plans.QueryResults) (HookAction, error) { +func (h *MockHook) PostListQuery(id HookResourceIdentity, results plans.QueryResults, identityVersion int64) (HookAction, error) { h.Lock() defer h.Unlock() diff --git a/internal/terraform/hook_stop.go b/internal/terraform/hook_stop.go index 18029076ce..fe34bc81f3 100644 --- a/internal/terraform/hook_stop.go +++ b/internal/terraform/hook_stop.go @@ -102,7 +102,7 @@ func (h *stopHook) PreListQuery(id HookResourceIdentity, input_config cty.Value) return h.hook() } -func (h *stopHook) PostListQuery(id HookResourceIdentity, results plans.QueryResults) (HookAction, error) { +func (h *stopHook) PostListQuery(id HookResourceIdentity, results plans.QueryResults, identityVersion int64) (HookAction, error) { return h.hook() } diff --git a/internal/terraform/hook_test.go b/internal/terraform/hook_test.go index b10a3bbd9a..3e94aacd45 100644 --- a/internal/terraform/hook_test.go +++ b/internal/terraform/hook_test.go @@ -176,7 +176,7 @@ func (h *testHook) PreListQuery(id HookResourceIdentity, input_config cty.Value) return HookActionContinue, nil } -func (h *testHook) PostListQuery(id HookResourceIdentity, results plans.QueryResults) (HookAction, error) { +func (h *testHook) PostListQuery(id HookResourceIdentity, results plans.QueryResults, identityVersion int64) (HookAction, error) { h.mu.Lock() defer h.mu.Unlock() h.Calls = append(h.Calls, &testHookCall{"PostListQuery", id.Addr.String()}) diff --git a/internal/terraform/node_resource_plan_instance_query.go b/internal/terraform/node_resource_plan_instance_query.go index 30a6689ba1..2a517f3af8 100644 --- a/internal/terraform/node_resource_plan_instance_query.go +++ b/internal/terraform/node_resource_plan_instance_query.go @@ -7,6 +7,7 @@ import ( "fmt" "log" + "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/tfdiags" @@ -113,8 +114,10 @@ func (n *NodePlannableResourceInstance) listResourceExecute(ctx EvalContext) (di } } + identityVersion := providerSchema.SchemaForResourceType(addrs.ManagedResourceMode, addr.Resource.Resource.Type).IdentityVersion + ctx.Hook(func(h Hook) (HookAction, error) { - return h.PostListQuery(rId, results) + return h.PostListQuery(rId, results, identityVersion) }) diags = diags.Append(resp.Diagnostics.InConfigBody(config.Config, n.Addr.String())) if diags.HasErrors() {