terraform/internal/command/views/hook_json_test.go

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

926 lines
30 KiB
Go
Raw Permalink Normal View History

// Copyright IBM Corp. 2014, 2026
// SPDX-License-Identifier: BUSL-1.1
package views
import (
"errors"
"fmt"
2022-04-20 15:01:17 -04:00
"sync"
"testing"
"testing/synctest"
"time"
"github.com/zclconf/go-cty/cty"
"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/configs"
"github.com/hashicorp/terraform/internal/plans"
"github.com/hashicorp/terraform/internal/terminal"
"github.com/hashicorp/terraform/internal/terraform"
)
Refactor terraform.Hook to use a resource-identifying wrapper struct The terraform.Hook interface lets other areas of code perform streaming reactions to various events, generally in the service of some UI somewhere. Nearly all of the methods on this interface take an `addrs.AbsResourceInstance` as their first argument, to identify the resource that's being operated on. However, that addrs struct doesn't necessarily contain everything you might want in order to uniquely and usefully identify a resource. It has the module instance and resource instance addresses, but it lacks the provider source address, which can affect how the consuming UI should display the resource's events. (For example, Terraform Cloud wants reliable info about who maintains a given provider, what cloud provider it operates on, and where to find its documentation.) Instead of polluting `addrs.AbsResourceInstance` with extra information that isn't relevant to other call sites, let's change the first argument of each Hook method to be a wrapper struct defined in the package that owns the Hook interface, and add the provider address to that wrapper as a sibling of the resource address. This causes a big noisy commit today, but should streamline future updates to the UI-facing "identity" of a resource; existing callers can ignore any new fields they're uninterested in, or exploit new info as needed. Other than making new information available for future edits to Hook implementing types, this commit should have no effect on existing behavior.
2024-02-27 20:42:17 -05:00
func testJSONHookResourceID(addr addrs.AbsResourceInstance) terraform.HookResourceIdentity {
return terraform.HookResourceIdentity{
Addr: addr,
ProviderAddr: addrs.Provider{
Type: "test",
Namespace: "hashicorp",
Hostname: "example.com",
},
}
}
func testJSONLifecycleHook(actionAddr addrs.AbsActionInstance, triggeringResourceAddr addrs.AbsResourceInstance, actionTriggerIndex int, actionsListIndex int) terraform.HookActionIdentity {
2025-07-18 12:36:08 -04:00
return terraform.HookActionIdentity{
Addr: actionAddr,
ActionTrigger: &plans.ResourceActionTrigger{
TriggeringResourceAddr: triggeringResourceAddr,
ActionTriggerBlockIndex: actionTriggerIndex,
ActionsListIndex: actionsListIndex,
ActionTriggerEvent: configs.AfterCreate,
},
}
}
func testJSONInvokeHook(actionAddr addrs.AbsActionInstance) terraform.HookActionIdentity {
return terraform.HookActionIdentity{
Addr: actionAddr,
ActionTrigger: new(plans.InvokeActionTrigger),
}
}
// Test a sequence of hooks associated with creating a resource
func TestJSONHook_create(t *testing.T) {
streams, done := terminal.StreamsForTesting(t)
hook := newJSONHook(NewJSONView(NewView(streams)))
2022-04-20 15:01:17 -04:00
var nowMu sync.Mutex
now := time.Now()
2022-04-20 15:01:17 -04:00
hook.timeNow = func() time.Time {
nowMu.Lock()
defer nowMu.Unlock()
return now
}
after := make(chan time.Time, 1)
hook.timeAfter = func(time.Duration) <-chan time.Time { return after }
addr := addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "test_instance",
Name: "boop",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance)
priorState := cty.NullVal(cty.Object(map[string]cty.Type{
"id": cty.String,
"bar": cty.List(cty.String),
}))
plannedNewState := cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("test"),
"bar": cty.ListVal([]cty.Value{
cty.StringVal("baz"),
}),
})
Refactor terraform.Hook to use a resource-identifying wrapper struct The terraform.Hook interface lets other areas of code perform streaming reactions to various events, generally in the service of some UI somewhere. Nearly all of the methods on this interface take an `addrs.AbsResourceInstance` as their first argument, to identify the resource that's being operated on. However, that addrs struct doesn't necessarily contain everything you might want in order to uniquely and usefully identify a resource. It has the module instance and resource instance addresses, but it lacks the provider source address, which can affect how the consuming UI should display the resource's events. (For example, Terraform Cloud wants reliable info about who maintains a given provider, what cloud provider it operates on, and where to find its documentation.) Instead of polluting `addrs.AbsResourceInstance` with extra information that isn't relevant to other call sites, let's change the first argument of each Hook method to be a wrapper struct defined in the package that owns the Hook interface, and add the provider address to that wrapper as a sibling of the resource address. This causes a big noisy commit today, but should streamline future updates to the UI-facing "identity" of a resource; existing callers can ignore any new fields they're uninterested in, or exploit new info as needed. Other than making new information available for future edits to Hook implementing types, this commit should have no effect on existing behavior.
2024-02-27 20:42:17 -05:00
action, err := hook.PreApply(testJSONHookResourceID(addr), addrs.NotDeposed, plans.Create, priorState, plannedNewState)
testHookReturnValues(t, action, err)
Refactor terraform.Hook to use a resource-identifying wrapper struct The terraform.Hook interface lets other areas of code perform streaming reactions to various events, generally in the service of some UI somewhere. Nearly all of the methods on this interface take an `addrs.AbsResourceInstance` as their first argument, to identify the resource that's being operated on. However, that addrs struct doesn't necessarily contain everything you might want in order to uniquely and usefully identify a resource. It has the module instance and resource instance addresses, but it lacks the provider source address, which can affect how the consuming UI should display the resource's events. (For example, Terraform Cloud wants reliable info about who maintains a given provider, what cloud provider it operates on, and where to find its documentation.) Instead of polluting `addrs.AbsResourceInstance` with extra information that isn't relevant to other call sites, let's change the first argument of each Hook method to be a wrapper struct defined in the package that owns the Hook interface, and add the provider address to that wrapper as a sibling of the resource address. This causes a big noisy commit today, but should streamline future updates to the UI-facing "identity" of a resource; existing callers can ignore any new fields they're uninterested in, or exploit new info as needed. Other than making new information available for future edits to Hook implementing types, this commit should have no effect on existing behavior.
2024-02-27 20:42:17 -05:00
action, err = hook.PreProvisionInstanceStep(testJSONHookResourceID(addr), "local-exec")
testHookReturnValues(t, action, err)
Refactor terraform.Hook to use a resource-identifying wrapper struct The terraform.Hook interface lets other areas of code perform streaming reactions to various events, generally in the service of some UI somewhere. Nearly all of the methods on this interface take an `addrs.AbsResourceInstance` as their first argument, to identify the resource that's being operated on. However, that addrs struct doesn't necessarily contain everything you might want in order to uniquely and usefully identify a resource. It has the module instance and resource instance addresses, but it lacks the provider source address, which can affect how the consuming UI should display the resource's events. (For example, Terraform Cloud wants reliable info about who maintains a given provider, what cloud provider it operates on, and where to find its documentation.) Instead of polluting `addrs.AbsResourceInstance` with extra information that isn't relevant to other call sites, let's change the first argument of each Hook method to be a wrapper struct defined in the package that owns the Hook interface, and add the provider address to that wrapper as a sibling of the resource address. This causes a big noisy commit today, but should streamline future updates to the UI-facing "identity" of a resource; existing callers can ignore any new fields they're uninterested in, or exploit new info as needed. Other than making new information available for future edits to Hook implementing types, this commit should have no effect on existing behavior.
2024-02-27 20:42:17 -05:00
hook.ProvisionOutput(testJSONHookResourceID(addr), "local-exec", `Executing: ["/bin/sh" "-c" "touch /etc/motd"]`)
Refactor terraform.Hook to use a resource-identifying wrapper struct The terraform.Hook interface lets other areas of code perform streaming reactions to various events, generally in the service of some UI somewhere. Nearly all of the methods on this interface take an `addrs.AbsResourceInstance` as their first argument, to identify the resource that's being operated on. However, that addrs struct doesn't necessarily contain everything you might want in order to uniquely and usefully identify a resource. It has the module instance and resource instance addresses, but it lacks the provider source address, which can affect how the consuming UI should display the resource's events. (For example, Terraform Cloud wants reliable info about who maintains a given provider, what cloud provider it operates on, and where to find its documentation.) Instead of polluting `addrs.AbsResourceInstance` with extra information that isn't relevant to other call sites, let's change the first argument of each Hook method to be a wrapper struct defined in the package that owns the Hook interface, and add the provider address to that wrapper as a sibling of the resource address. This causes a big noisy commit today, but should streamline future updates to the UI-facing "identity" of a resource; existing callers can ignore any new fields they're uninterested in, or exploit new info as needed. Other than making new information available for future edits to Hook implementing types, this commit should have no effect on existing behavior.
2024-02-27 20:42:17 -05:00
action, err = hook.PostProvisionInstanceStep(testJSONHookResourceID(addr), "local-exec", nil)
testHookReturnValues(t, action, err)
// Travel 10s into the future, notify the progress goroutine, and sleep
// briefly to allow it to execute
2022-04-20 15:01:17 -04:00
nowMu.Lock()
now = now.Add(10 * time.Second)
after <- now
2022-04-20 15:01:17 -04:00
nowMu.Unlock()
time.Sleep(10 * time.Millisecond)
// Travel 10s into the future, notify the progress goroutine, and sleep
// briefly to allow it to execute
2022-04-20 15:01:17 -04:00
nowMu.Lock()
now = now.Add(10 * time.Second)
after <- now
2022-04-20 15:01:17 -04:00
nowMu.Unlock()
time.Sleep(10 * time.Millisecond)
// Travel 2s into the future. We have arrived!
2022-04-20 15:01:17 -04:00
nowMu.Lock()
now = now.Add(2 * time.Second)
2022-04-20 15:01:17 -04:00
nowMu.Unlock()
Refactor terraform.Hook to use a resource-identifying wrapper struct The terraform.Hook interface lets other areas of code perform streaming reactions to various events, generally in the service of some UI somewhere. Nearly all of the methods on this interface take an `addrs.AbsResourceInstance` as their first argument, to identify the resource that's being operated on. However, that addrs struct doesn't necessarily contain everything you might want in order to uniquely and usefully identify a resource. It has the module instance and resource instance addresses, but it lacks the provider source address, which can affect how the consuming UI should display the resource's events. (For example, Terraform Cloud wants reliable info about who maintains a given provider, what cloud provider it operates on, and where to find its documentation.) Instead of polluting `addrs.AbsResourceInstance` with extra information that isn't relevant to other call sites, let's change the first argument of each Hook method to be a wrapper struct defined in the package that owns the Hook interface, and add the provider address to that wrapper as a sibling of the resource address. This causes a big noisy commit today, but should streamline future updates to the UI-facing "identity" of a resource; existing callers can ignore any new fields they're uninterested in, or exploit new info as needed. Other than making new information available for future edits to Hook implementing types, this commit should have no effect on existing behavior.
2024-02-27 20:42:17 -05:00
action, err = hook.PostApply(testJSONHookResourceID(addr), addrs.NotDeposed, plannedNewState, nil)
testHookReturnValues(t, action, err)
// Shut down the progress goroutine if still active
hook.resourceProgressMu.Lock()
for key, progress := range hook.resourceProgress {
close(progress.done)
<-progress.heartbeatDone
delete(hook.resourceProgress, key)
}
hook.resourceProgressMu.Unlock()
wantResource := map[string]interface{}{
"addr": string("test_instance.boop"),
"implied_provider": string("test"),
"module": string(""),
"resource": string("test_instance.boop"),
"resource_key": nil,
"resource_name": string("boop"),
"resource_type": string("test_instance"),
}
want := []map[string]interface{}{
{
"@level": "info",
"@message": "test_instance.boop: Creating...",
"@module": "terraform.ui",
"type": "apply_start",
"hook": map[string]interface{}{
"action": string("create"),
"resource": wantResource,
},
},
{
"@level": "info",
"@message": "test_instance.boop: Provisioning with 'local-exec'...",
"@module": "terraform.ui",
"type": "provision_start",
"hook": map[string]interface{}{
"provisioner": "local-exec",
"resource": wantResource,
},
},
{
"@level": "info",
"@message": `test_instance.boop: (local-exec): Executing: ["/bin/sh" "-c" "touch /etc/motd"]`,
"@module": "terraform.ui",
"type": "provision_progress",
"hook": map[string]interface{}{
"output": `Executing: ["/bin/sh" "-c" "touch /etc/motd"]`,
"provisioner": "local-exec",
"resource": wantResource,
},
},
{
"@level": "info",
"@message": "test_instance.boop: (local-exec) Provisioning complete",
"@module": "terraform.ui",
"type": "provision_complete",
"hook": map[string]interface{}{
"provisioner": "local-exec",
"resource": wantResource,
},
},
{
"@level": "info",
"@message": "test_instance.boop: Still creating... [10s elapsed]",
"@module": "terraform.ui",
"type": "apply_progress",
"hook": map[string]interface{}{
"action": string("create"),
"elapsed_seconds": float64(10),
"resource": wantResource,
},
},
{
"@level": "info",
"@message": "test_instance.boop: Still creating... [20s elapsed]",
"@module": "terraform.ui",
"type": "apply_progress",
"hook": map[string]interface{}{
"action": string("create"),
"elapsed_seconds": float64(20),
"resource": wantResource,
},
},
{
"@level": "info",
"@message": "test_instance.boop: Creation complete after 22s [id=test]",
"@module": "terraform.ui",
"type": "apply_complete",
"hook": map[string]interface{}{
"action": string("create"),
"elapsed_seconds": float64(22),
"id_key": "id",
"id_value": "test",
"resource": wantResource,
},
},
}
testJSONViewOutputEquals(t, done(t).Stdout(), want)
}
func TestJSONHook_errors(t *testing.T) {
streams, done := terminal.StreamsForTesting(t)
hook := newJSONHook(NewJSONView(NewView(streams)))
addr := addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "test_instance",
Name: "boop",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance)
priorState := cty.NullVal(cty.Object(map[string]cty.Type{
"id": cty.String,
"bar": cty.List(cty.String),
}))
plannedNewState := cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("test"),
"bar": cty.ListVal([]cty.Value{
cty.StringVal("baz"),
}),
})
Refactor terraform.Hook to use a resource-identifying wrapper struct The terraform.Hook interface lets other areas of code perform streaming reactions to various events, generally in the service of some UI somewhere. Nearly all of the methods on this interface take an `addrs.AbsResourceInstance` as their first argument, to identify the resource that's being operated on. However, that addrs struct doesn't necessarily contain everything you might want in order to uniquely and usefully identify a resource. It has the module instance and resource instance addresses, but it lacks the provider source address, which can affect how the consuming UI should display the resource's events. (For example, Terraform Cloud wants reliable info about who maintains a given provider, what cloud provider it operates on, and where to find its documentation.) Instead of polluting `addrs.AbsResourceInstance` with extra information that isn't relevant to other call sites, let's change the first argument of each Hook method to be a wrapper struct defined in the package that owns the Hook interface, and add the provider address to that wrapper as a sibling of the resource address. This causes a big noisy commit today, but should streamline future updates to the UI-facing "identity" of a resource; existing callers can ignore any new fields they're uninterested in, or exploit new info as needed. Other than making new information available for future edits to Hook implementing types, this commit should have no effect on existing behavior.
2024-02-27 20:42:17 -05:00
action, err := hook.PreApply(testJSONHookResourceID(addr), addrs.NotDeposed, plans.Delete, priorState, plannedNewState)
testHookReturnValues(t, action, err)
provisionError := fmt.Errorf("provisioner didn't want to")
Refactor terraform.Hook to use a resource-identifying wrapper struct The terraform.Hook interface lets other areas of code perform streaming reactions to various events, generally in the service of some UI somewhere. Nearly all of the methods on this interface take an `addrs.AbsResourceInstance` as their first argument, to identify the resource that's being operated on. However, that addrs struct doesn't necessarily contain everything you might want in order to uniquely and usefully identify a resource. It has the module instance and resource instance addresses, but it lacks the provider source address, which can affect how the consuming UI should display the resource's events. (For example, Terraform Cloud wants reliable info about who maintains a given provider, what cloud provider it operates on, and where to find its documentation.) Instead of polluting `addrs.AbsResourceInstance` with extra information that isn't relevant to other call sites, let's change the first argument of each Hook method to be a wrapper struct defined in the package that owns the Hook interface, and add the provider address to that wrapper as a sibling of the resource address. This causes a big noisy commit today, but should streamline future updates to the UI-facing "identity" of a resource; existing callers can ignore any new fields they're uninterested in, or exploit new info as needed. Other than making new information available for future edits to Hook implementing types, this commit should have no effect on existing behavior.
2024-02-27 20:42:17 -05:00
action, err = hook.PostProvisionInstanceStep(testJSONHookResourceID(addr), "local-exec", provisionError)
testHookReturnValues(t, action, err)
applyError := fmt.Errorf("provider was sad")
Refactor terraform.Hook to use a resource-identifying wrapper struct The terraform.Hook interface lets other areas of code perform streaming reactions to various events, generally in the service of some UI somewhere. Nearly all of the methods on this interface take an `addrs.AbsResourceInstance` as their first argument, to identify the resource that's being operated on. However, that addrs struct doesn't necessarily contain everything you might want in order to uniquely and usefully identify a resource. It has the module instance and resource instance addresses, but it lacks the provider source address, which can affect how the consuming UI should display the resource's events. (For example, Terraform Cloud wants reliable info about who maintains a given provider, what cloud provider it operates on, and where to find its documentation.) Instead of polluting `addrs.AbsResourceInstance` with extra information that isn't relevant to other call sites, let's change the first argument of each Hook method to be a wrapper struct defined in the package that owns the Hook interface, and add the provider address to that wrapper as a sibling of the resource address. This causes a big noisy commit today, but should streamline future updates to the UI-facing "identity" of a resource; existing callers can ignore any new fields they're uninterested in, or exploit new info as needed. Other than making new information available for future edits to Hook implementing types, this commit should have no effect on existing behavior.
2024-02-27 20:42:17 -05:00
action, err = hook.PostApply(testJSONHookResourceID(addr), addrs.NotDeposed, plannedNewState, applyError)
testHookReturnValues(t, action, err)
// Shut down the progress goroutine
hook.resourceProgressMu.Lock()
for key, progress := range hook.resourceProgress {
close(progress.done)
<-progress.heartbeatDone
delete(hook.resourceProgress, key)
}
hook.resourceProgressMu.Unlock()
wantResource := map[string]interface{}{
"addr": string("test_instance.boop"),
"implied_provider": string("test"),
"module": string(""),
"resource": string("test_instance.boop"),
"resource_key": nil,
"resource_name": string("boop"),
"resource_type": string("test_instance"),
}
want := []map[string]interface{}{
{
"@level": "info",
"@message": "test_instance.boop: Destroying...",
"@module": "terraform.ui",
"type": "apply_start",
"hook": map[string]interface{}{
"action": string("delete"),
"resource": wantResource,
},
},
{
"@level": "info",
"@message": "test_instance.boop: (local-exec) Provisioning errored",
"@module": "terraform.ui",
"type": "provision_errored",
"hook": map[string]interface{}{
"provisioner": "local-exec",
"resource": wantResource,
},
},
{
"@level": "info",
"@message": "test_instance.boop: Destruction errored after 0s",
"@module": "terraform.ui",
"type": "apply_errored",
"hook": map[string]interface{}{
"action": string("delete"),
"elapsed_seconds": float64(0),
"resource": wantResource,
},
},
}
testJSONViewOutputEquals(t, done(t).Stdout(), want)
}
func TestJSONHook_refresh(t *testing.T) {
streams, done := terminal.StreamsForTesting(t)
hook := newJSONHook(NewJSONView(NewView(streams)))
addr := addrs.Resource{
Mode: addrs.DataResourceMode,
Type: "test_data_source",
Name: "beep",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance)
state := cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("honk"),
"bar": cty.ListVal([]cty.Value{
cty.StringVal("baz"),
}),
})
Refactor terraform.Hook to use a resource-identifying wrapper struct The terraform.Hook interface lets other areas of code perform streaming reactions to various events, generally in the service of some UI somewhere. Nearly all of the methods on this interface take an `addrs.AbsResourceInstance` as their first argument, to identify the resource that's being operated on. However, that addrs struct doesn't necessarily contain everything you might want in order to uniquely and usefully identify a resource. It has the module instance and resource instance addresses, but it lacks the provider source address, which can affect how the consuming UI should display the resource's events. (For example, Terraform Cloud wants reliable info about who maintains a given provider, what cloud provider it operates on, and where to find its documentation.) Instead of polluting `addrs.AbsResourceInstance` with extra information that isn't relevant to other call sites, let's change the first argument of each Hook method to be a wrapper struct defined in the package that owns the Hook interface, and add the provider address to that wrapper as a sibling of the resource address. This causes a big noisy commit today, but should streamline future updates to the UI-facing "identity" of a resource; existing callers can ignore any new fields they're uninterested in, or exploit new info as needed. Other than making new information available for future edits to Hook implementing types, this commit should have no effect on existing behavior.
2024-02-27 20:42:17 -05:00
action, err := hook.PreRefresh(testJSONHookResourceID(addr), addrs.NotDeposed, state)
testHookReturnValues(t, action, err)
Refactor terraform.Hook to use a resource-identifying wrapper struct The terraform.Hook interface lets other areas of code perform streaming reactions to various events, generally in the service of some UI somewhere. Nearly all of the methods on this interface take an `addrs.AbsResourceInstance` as their first argument, to identify the resource that's being operated on. However, that addrs struct doesn't necessarily contain everything you might want in order to uniquely and usefully identify a resource. It has the module instance and resource instance addresses, but it lacks the provider source address, which can affect how the consuming UI should display the resource's events. (For example, Terraform Cloud wants reliable info about who maintains a given provider, what cloud provider it operates on, and where to find its documentation.) Instead of polluting `addrs.AbsResourceInstance` with extra information that isn't relevant to other call sites, let's change the first argument of each Hook method to be a wrapper struct defined in the package that owns the Hook interface, and add the provider address to that wrapper as a sibling of the resource address. This causes a big noisy commit today, but should streamline future updates to the UI-facing "identity" of a resource; existing callers can ignore any new fields they're uninterested in, or exploit new info as needed. Other than making new information available for future edits to Hook implementing types, this commit should have no effect on existing behavior.
2024-02-27 20:42:17 -05:00
action, err = hook.PostRefresh(testJSONHookResourceID(addr), addrs.NotDeposed, state, state)
testHookReturnValues(t, action, err)
wantResource := map[string]interface{}{
"addr": string("data.test_data_source.beep"),
"implied_provider": string("test"),
"module": string(""),
"resource": string("data.test_data_source.beep"),
"resource_key": nil,
"resource_name": string("beep"),
"resource_type": string("test_data_source"),
}
want := []map[string]interface{}{
{
"@level": "info",
"@message": "data.test_data_source.beep: Refreshing state... [id=honk]",
"@module": "terraform.ui",
"type": "refresh_start",
"hook": map[string]interface{}{
"resource": wantResource,
"id_key": "id",
"id_value": "honk",
},
},
{
"@level": "info",
"@message": "data.test_data_source.beep: Refresh complete [id=honk]",
"@module": "terraform.ui",
"type": "refresh_complete",
"hook": map[string]interface{}{
"resource": wantResource,
"id_key": "id",
"id_value": "honk",
},
},
}
testJSONViewOutputEquals(t, done(t).Stdout(), want)
}
func TestJSONHook_EphemeralOp(t *testing.T) {
streams, done := terminal.StreamsForTesting(t)
hook := newJSONHook(NewJSONView(NewView(streams)))
addr := addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "test_instance",
Name: "boop",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance)
action, err := hook.PreEphemeralOp(testJSONHookResourceID(addr), plans.Open)
testHookReturnValues(t, action, err)
action, err = hook.PostEphemeralOp(testJSONHookResourceID(addr), plans.Open, nil)
testHookReturnValues(t, action, err)
want := []map[string]interface{}{
{
"@level": "info",
"@message": "test_instance.boop: Opening...",
"@module": "terraform.ui",
"type": "ephemeral_op_start",
"hook": map[string]interface{}{
"action": string("open"),
"resource": map[string]interface{}{
"addr": string("test_instance.boop"),
"implied_provider": string("test"),
"module": string(""),
"resource": string("test_instance.boop"),
"resource_key": nil,
"resource_name": string("boop"),
"resource_type": string("test_instance"),
},
},
},
{
"@level": "info",
"@message": "test_instance.boop: Opening complete after 0s",
"@module": "terraform.ui",
"type": "ephemeral_op_complete",
"hook": map[string]interface{}{
"action": string("open"),
"elapsed_seconds": float64(0),
"resource": map[string]interface{}{
"addr": string("test_instance.boop"),
"implied_provider": string("test"),
"module": string(""),
"resource": string("test_instance.boop"),
"resource_key": nil,
"resource_name": string("boop"),
"resource_type": string("test_instance"),
},
},
},
}
testJSONViewOutputEquals(t, done(t).Stdout(), want)
}
func TestJSONHook_EphemeralOp_progress(t *testing.T) {
syncTest, streams, done := streamableSyncTest(t)
syncTest(t, func(t *testing.T) {
start := time.Now()
hook := newJSONHook(NewJSONView(NewView(streams)))
hook.periodicUiTimer = 1 * time.Second
t.Log(time.Since(start))
addr := addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "test_instance",
Name: "boop",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance)
action, err := hook.PreEphemeralOp(testJSONHookResourceID(addr), plans.Open)
testHookReturnValues(t, action, err)
time.Sleep(2005 * time.Millisecond)
action, err = hook.PostEphemeralOp(testJSONHookResourceID(addr), plans.Open, nil)
testHookReturnValues(t, action, err)
want := []map[string]interface{}{
{
"@level": "info",
"@message": "test_instance.boop: Opening...",
"@module": "terraform.ui",
"type": "ephemeral_op_start",
"hook": map[string]interface{}{
"action": string("open"),
"resource": map[string]interface{}{
"addr": string("test_instance.boop"),
"implied_provider": string("test"),
"module": string(""),
"resource": string("test_instance.boop"),
"resource_key": nil,
"resource_name": string("boop"),
"resource_type": string("test_instance"),
},
},
},
{
"@level": "info",
"@message": "test_instance.boop: Still opening... [1s elapsed]",
"@module": "terraform.ui",
"type": "ephemeral_op_progress",
"hook": map[string]interface{}{
"action": string("open"),
"elapsed_seconds": float64(1),
"resource": map[string]interface{}{
"addr": string("test_instance.boop"),
"implied_provider": string("test"),
"module": string(""),
"resource": string("test_instance.boop"),
"resource_key": nil,
"resource_name": string("boop"),
"resource_type": string("test_instance"),
},
},
},
{
"@level": "info",
"@message": "test_instance.boop: Still opening... [2s elapsed]",
"@module": "terraform.ui",
"type": "ephemeral_op_progress",
"hook": map[string]interface{}{
"action": string("open"),
"elapsed_seconds": float64(2),
"resource": map[string]interface{}{
"addr": string("test_instance.boop"),
"implied_provider": string("test"),
"module": string(""),
"resource": string("test_instance.boop"),
"resource_key": nil,
"resource_name": string("boop"),
"resource_type": string("test_instance"),
},
},
},
{
"@level": "info",
"@message": "test_instance.boop: Opening complete after 2s",
"@module": "terraform.ui",
"type": "ephemeral_op_complete",
"hook": map[string]interface{}{
"action": string("open"),
"elapsed_seconds": float64(2),
"resource": map[string]interface{}{
"addr": string("test_instance.boop"),
"implied_provider": string("test"),
"module": string(""),
"resource": string("test_instance.boop"),
"resource_key": nil,
"resource_name": string("boop"),
"resource_type": string("test_instance"),
},
},
},
}
stdout := done(t).Stdout()
testJSONViewOutputEquals(t, stdout, want)
})
}
func TestJSONHook_EphemeralOp_error(t *testing.T) {
streams, done := terminal.StreamsForTesting(t)
hook := newJSONHook(NewJSONView(NewView(streams)))
addr := addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "test_instance",
Name: "boop",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance)
action, err := hook.PreEphemeralOp(testJSONHookResourceID(addr), plans.Open)
testHookReturnValues(t, action, err)
action, err = hook.PostEphemeralOp(testJSONHookResourceID(addr), plans.Open, errors.New("test error"))
testHookReturnValues(t, action, err)
want := []map[string]interface{}{
{
"@level": "info",
"@message": "test_instance.boop: Opening...",
"@module": "terraform.ui",
"type": "ephemeral_op_start",
"hook": map[string]interface{}{
"action": string("open"),
"resource": map[string]interface{}{
"addr": string("test_instance.boop"),
"implied_provider": string("test"),
"module": string(""),
"resource": string("test_instance.boop"),
"resource_key": nil,
"resource_name": string("boop"),
"resource_type": string("test_instance"),
},
},
},
{
"@level": "info",
"@message": "test_instance.boop: Opening errored after 0s",
"@module": "terraform.ui",
"type": "ephemeral_op_errored",
"hook": map[string]interface{}{
"action": string("open"),
"elapsed_seconds": float64(0),
"resource": map[string]interface{}{
"addr": string("test_instance.boop"),
"implied_provider": string("test"),
"module": string(""),
"resource": string("test_instance.boop"),
"resource_key": nil,
"resource_name": string("boop"),
"resource_type": string("test_instance"),
},
},
},
}
testJSONViewOutputEquals(t, done(t).Stdout(), want)
}
func TestJSONHook_actions(t *testing.T) {
streams, done := terminal.StreamsForTesting(t)
hook := newJSONHook(NewJSONView(NewView(streams)))
actionA := addrs.AbsActionInstance{
Module: addrs.RootModuleInstance,
Action: addrs.Action{
Type: "aws_lambda_invocation",
Name: "notify_slack",
}.Instance(addrs.IntKey(42)),
}
resourceA := addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "test_instance",
Name: "boop",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance)
subModule := addrs.RootModuleInstance.Child("childMod", addrs.StringKey("infra"))
actionB := addrs.AbsActionInstance{
Module: subModule,
Action: addrs.Action{
Type: "ansible_playbook",
Name: "webserver",
}.Instance(addrs.NoKey),
}
resourceB := addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "test_instance",
Name: "boop",
}.Instance(addrs.NoKey).Absolute(subModule)
actionC := addrs.AbsActionInstance{
Module: addrs.RootModuleInstance,
Action: addrs.Action{
Type: "aws_lambda_invocation",
Name: "invoke_me",
}.Instance(addrs.IntKey(42)),
}
actionAHookId := testJSONLifecycleHook(actionA, resourceA, 0, 1)
actionBHookId := testJSONLifecycleHook(actionB, resourceB, 2, 3)
actionCHookId := testJSONInvokeHook(actionC)
2025-07-18 12:36:08 -04:00
action, err := hook.StartAction(actionAHookId)
testHookReturnValues(t, action, err)
2025-07-18 12:36:08 -04:00
action, err = hook.ProgressAction(actionAHookId, "Hello world from the lambda function")
testHookReturnValues(t, action, err)
2025-07-18 12:36:08 -04:00
action, err = hook.StartAction(actionBHookId)
testHookReturnValues(t, action, err)
2025-07-18 12:36:08 -04:00
action, err = hook.ProgressAction(actionBHookId, "TASK: [hello]\n ok: [localhost] => (item=Hello world from the ansible playbook]")
testHookReturnValues(t, action, err)
2025-07-18 12:36:08 -04:00
action, err = hook.CompleteAction(actionBHookId, nil)
testHookReturnValues(t, action, err)
2025-07-18 12:36:08 -04:00
action, err = hook.CompleteAction(actionAHookId, errors.New("lambda terminated with exit code 1"))
testHookReturnValues(t, action, err)
action, err = hook.StartAction(actionCHookId)
testHookReturnValues(t, action, err)
action, err = hook.ProgressAction(actionCHookId, "Hello world from the invoked action")
testHookReturnValues(t, action, err)
action, err = hook.CompleteAction(actionCHookId, nil)
testHookReturnValues(t, action, err)
want := []map[string]interface{}{
{
"@level": "info",
"@message": "test_instance.boop.trigger[0]: Action started: action.aws_lambda_invocation.notify_slack[42]",
"@module": "terraform.ui",
"type": "action_start",
"hook": map[string]interface{}{
"action": map[string]interface{}{
"addr": "action.aws_lambda_invocation.notify_slack[42]",
"module": "",
"implied_provider": "aws",
"action": "action.aws_lambda_invocation.notify_slack[42]",
"action_key": float64(42),
"action_name": "notify_slack",
"action_type": "aws_lambda_invocation",
},
"lifecycle": map[string]interface{}{
"actions_index": float64(1),
"resource": map[string]interface{}{
"addr": "test_instance.boop",
"implied_provider": "test",
"module": "",
"resource": "test_instance.boop",
"resource_key": nil,
"resource_name": "boop",
"resource_type": "test_instance",
},
"trigger_event": "AfterCreate",
"trigger_index": float64(0),
},
},
},
{
"@level": "info",
2025-07-18 12:36:08 -04:00
"@message": "test_instance.boop (0): action.aws_lambda_invocation.notify_slack[42] - Hello world from the lambda function",
"@module": "terraform.ui",
"type": "action_progress",
"hook": map[string]interface{}{
"action": map[string]interface{}{
"addr": "action.aws_lambda_invocation.notify_slack[42]",
"module": "",
"implied_provider": "aws",
"action": "action.aws_lambda_invocation.notify_slack[42]",
"action_key": float64(42),
"action_name": "notify_slack",
"action_type": "aws_lambda_invocation",
},
"message": "Hello world from the lambda function",
"lifecycle": map[string]interface{}{
"actions_index": float64(1),
"resource": map[string]interface{}{
"addr": "test_instance.boop",
"implied_provider": "test",
"module": "",
"resource": "test_instance.boop",
"resource_key": nil,
"resource_name": "boop",
"resource_type": "test_instance",
},
"trigger_event": "AfterCreate",
"trigger_index": float64(0),
},
},
},
{
"@level": "info",
"@message": "module.childMod[\"infra\"].test_instance.boop.trigger[2]: Action started: module.childMod[\"infra\"].action.ansible_playbook.webserver",
"@module": "terraform.ui",
"type": "action_start",
"hook": map[string]interface{}{
"action": map[string]interface{}{
"addr": "module.childMod[\"infra\"].action.ansible_playbook.webserver",
"module": "module.childMod[\"infra\"]",
"implied_provider": "ansible",
"action": "action.ansible_playbook.webserver",
"action_key": nil,
"action_name": "webserver",
"action_type": "ansible_playbook",
},
"lifecycle": map[string]interface{}{
"actions_index": float64(3),
"resource": map[string]interface{}{
"addr": "module.childMod[\"infra\"].test_instance.boop",
"implied_provider": "test",
"module": "module.childMod[\"infra\"]",
"resource": "test_instance.boop",
"resource_key": nil,
"resource_name": "boop",
"resource_type": "test_instance",
},
"trigger_event": "AfterCreate",
"trigger_index": float64(2),
},
},
},
{
"@level": "info",
2025-07-18 12:36:08 -04:00
"@message": "module.childMod[\"infra\"].test_instance.boop (2): module.childMod[\"infra\"].action.ansible_playbook.webserver - TASK: [hello]\n ok: [localhost] => (item=Hello world from the ansible playbook]",
"@module": "terraform.ui",
"type": "action_progress",
"hook": map[string]interface{}{
"action": map[string]interface{}{
"addr": "module.childMod[\"infra\"].action.ansible_playbook.webserver",
"module": "module.childMod[\"infra\"]",
"implied_provider": "ansible",
"action": "action.ansible_playbook.webserver",
"action_key": nil,
"action_name": "webserver",
"action_type": "ansible_playbook",
},
"message": "TASK: [hello]\n ok: [localhost] => (item=Hello world from the ansible playbook]",
"lifecycle": map[string]interface{}{
"actions_index": float64(3),
"resource": map[string]interface{}{
"addr": "module.childMod[\"infra\"].test_instance.boop",
"implied_provider": "test",
"module": "module.childMod[\"infra\"]",
"resource": "test_instance.boop",
"resource_key": nil,
"resource_name": "boop",
"resource_type": "test_instance",
},
"trigger_event": "AfterCreate",
"trigger_index": float64(2),
},
},
},
{
"@level": "info",
"@message": "module.childMod[\"infra\"].test_instance.boop (2): Action complete: module.childMod[\"infra\"].action.ansible_playbook.webserver",
"@module": "terraform.ui",
"type": "action_complete",
"hook": map[string]interface{}{
"action": map[string]interface{}{
"addr": "module.childMod[\"infra\"].action.ansible_playbook.webserver",
"module": "module.childMod[\"infra\"]",
"implied_provider": "ansible",
"action": "action.ansible_playbook.webserver",
"action_key": nil,
"action_name": "webserver",
"action_type": "ansible_playbook",
},
"lifecycle": map[string]interface{}{
"actions_index": float64(3),
"resource": map[string]interface{}{
"addr": "module.childMod[\"infra\"].test_instance.boop",
"implied_provider": "test",
"module": "module.childMod[\"infra\"]",
"resource": "test_instance.boop",
"resource_key": nil,
"resource_name": "boop",
"resource_type": "test_instance",
},
"trigger_event": "AfterCreate",
"trigger_index": float64(2),
},
},
},
{
"@level": "info",
"@message": "test_instance.boop (0): Action errored: action.aws_lambda_invocation.notify_slack[42] - lambda terminated with exit code 1",
"@module": "terraform.ui",
"type": "action_errored",
"hook": map[string]interface{}{
"action": map[string]interface{}{
"addr": "action.aws_lambda_invocation.notify_slack[42]",
"module": "",
"implied_provider": "aws",
"action": "action.aws_lambda_invocation.notify_slack[42]",
"action_key": float64(42),
"action_name": "notify_slack",
"action_type": "aws_lambda_invocation",
},
"error": "lambda terminated with exit code 1",
"lifecycle": map[string]interface{}{
"actions_index": float64(1),
"resource": map[string]interface{}{
"addr": "test_instance.boop",
"implied_provider": "test",
"module": "",
"resource": "test_instance.boop",
"resource_key": nil,
"resource_name": "boop",
"resource_type": "test_instance",
},
"trigger_event": "AfterCreate",
"trigger_index": float64(0),
},
},
},
{
"@level": "info",
"@message": "Action started: action.aws_lambda_invocation.invoke_me[42]",
"@module": "terraform.ui",
"type": "action_start",
"hook": map[string]interface{}{
"action": map[string]interface{}{
"addr": "action.aws_lambda_invocation.invoke_me[42]",
"module": "",
"implied_provider": "aws",
"action": "action.aws_lambda_invocation.invoke_me[42]",
"action_key": float64(42),
"action_name": "invoke_me",
"action_type": "aws_lambda_invocation",
},
"invoke": map[string]interface{}{},
},
},
{
"@level": "info",
"@message": "action.aws_lambda_invocation.invoke_me[42] - Hello world from the invoked action",
"@module": "terraform.ui",
"type": "action_progress",
"hook": map[string]interface{}{
"action": map[string]interface{}{
"addr": "action.aws_lambda_invocation.invoke_me[42]",
"module": "",
"implied_provider": "aws",
"action": "action.aws_lambda_invocation.invoke_me[42]",
"action_key": float64(42),
"action_name": "invoke_me",
"action_type": "aws_lambda_invocation",
},
"message": "Hello world from the invoked action",
"invoke": map[string]interface{}{},
},
},
{
"@level": "info",
"@message": "Action complete: action.aws_lambda_invocation.invoke_me[42]",
"@module": "terraform.ui",
"type": "action_complete",
"hook": map[string]interface{}{
"action": map[string]interface{}{
"addr": "action.aws_lambda_invocation.invoke_me[42]",
"module": "",
"implied_provider": "aws",
"action": "action.aws_lambda_invocation.invoke_me[42]",
"action_key": float64(42),
"action_name": "invoke_me",
"action_type": "aws_lambda_invocation",
},
"invoke": map[string]interface{}{},
},
},
}
testJSONViewOutputEquals(t, done(t).Stdout(), want)
}
func testHookReturnValues(t *testing.T, action terraform.HookAction, err error) {
t.Helper()
if err != nil {
t.Fatal(err)
}
if action != terraform.HookActionContinue {
t.Fatalf("Expected hook to continue, given: %#v", action)
}
}
// streamableSyncTest is a helper to ensure that the long-running streaming goroutines are started outside of the synctest bubble.
// Otherwise, the sync bubble will be unable to advance time, and the main goroutine will become infinitely paused on any time.Sleep operation.
func streamableSyncTest(t *testing.T) (func(t *testing.T, f func(*testing.T)), *terminal.Streams, func(*testing.T) *terminal.TestOutput) {
streams, done := terminal.StreamsForTesting(t)
return synctest.Test, streams, done
}