terraform: Emit lifecycle phases of ephemeral resources to the UI (#35919)

pull/35899/head^2
Radek Simko 1 year ago committed by GitHub
parent 10530bbee1
commit 0a266c88c4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -62,8 +62,12 @@ const (
LogResourceDrift JSONLogType = "resource_drift"
LogVersion JSONLogType = "version"
// Test Messages
// Ephemeral operation messages
LogEphemeralOpStart JSONLogType = "ephemeral_op_start"
LogEphemeralOpComplete JSONLogType = "ephemeral_op_complete"
LogEphemeralOpErrored JSONLogType = "ephemeral_op_errored"
// Test Messages
LogTestAbstract JSONLogType = "test_abstract"
LogTestFile JSONLogType = "test_file"
LogTestRun JSONLogType = "test_run"
@ -146,6 +150,7 @@ func (renderer Renderer) RenderLog(log *JSONLog) error {
LogProvisionComplete,
LogProvisionErrored,
LogApplyErrored,
LogEphemeralOpErrored,
LogTestAbstract,
LogTestStatus,
LogTestRetry,
@ -155,7 +160,7 @@ func (renderer Renderer) RenderLog(log *JSONLog) error {
// We won't display these types of logs
return nil
case LogApplyStart, LogApplyComplete, LogRefreshStart, LogProvisionStart, LogResourceDrift:
case LogApplyStart, LogApplyComplete, LogRefreshStart, LogProvisionStart, LogResourceDrift, LogEphemeralOpStart, LogEphemeralOpComplete:
msg := fmt.Sprintf(renderer.Colorize.Color("[bold]%s[reset]"), log.Message)
renderer.Streams.Println(msg)

@ -19,15 +19,13 @@ import (
"github.com/hashicorp/terraform/internal/terraform"
)
// How long to wait between sending heartbeat/progress messages
const heartbeatInterval = 10 * time.Second
func newJSONHook(view *JSONView) *jsonHook {
return &jsonHook{
view: view,
applying: make(map[string]applyProgress),
timeNow: time.Now,
timeAfter: time.After,
view: view,
resourceProgress: make(map[string]resourceProgress),
timeNow: time.Now,
timeAfter: time.After,
periodicUiTimer: defaultPeriodicUiTimer,
}
}
@ -36,24 +34,26 @@ type jsonHook struct {
view *JSONView
// Concurrent map of resource addresses to allow the sequence of pre-apply,
// progress, and post-apply messages to share data about the resource
applying map[string]applyProgress
applyingLock sync.Mutex
// Concurrent map of resource addresses to allow tracking
// progress, and post-action messages to share data about the resource
resourceProgress map[string]resourceProgress
resourceProgressMu sync.Mutex
// Mockable functions for testing the progress timer goroutine
timeNow func() time.Time
timeAfter func(time.Duration) <-chan time.Time
periodicUiTimer time.Duration
}
var _ terraform.Hook = (*jsonHook)(nil)
type applyProgress struct {
type resourceProgress struct {
addr addrs.AbsResourceInstance
action plans.Action
start time.Time
// done is used for post-apply to stop the progress goroutine
// done is used for post-action to stop the progress goroutine
done chan struct{}
// heartbeatDone is used to allow tests to safely wait for the progress
@ -67,16 +67,16 @@ func (h *jsonHook) PreApply(id terraform.HookResourceIdentity, dk addrs.DeposedK
h.view.Hook(json.NewApplyStart(id.Addr, action, idKey, idValue))
}
progress := applyProgress{
progress := resourceProgress{
addr: id.Addr,
action: action,
start: h.timeNow().Round(time.Second),
done: make(chan struct{}),
heartbeatDone: make(chan struct{}),
}
h.applyingLock.Lock()
h.applying[id.Addr.String()] = progress
h.applyingLock.Unlock()
h.resourceProgressMu.Lock()
h.resourceProgress[id.Addr.String()] = progress
h.resourceProgressMu.Unlock()
if action != plans.NoOp {
go h.applyingHeartbeat(progress)
@ -84,13 +84,13 @@ func (h *jsonHook) PreApply(id terraform.HookResourceIdentity, dk addrs.DeposedK
return terraform.HookActionContinue, nil
}
func (h *jsonHook) applyingHeartbeat(progress applyProgress) {
func (h *jsonHook) applyingHeartbeat(progress resourceProgress) {
defer close(progress.heartbeatDone)
for {
select {
case <-progress.done:
return
case <-h.timeAfter(heartbeatInterval):
case <-h.timeAfter(h.periodicUiTimer):
}
elapsed := h.timeNow().Round(time.Second).Sub(progress.start)
@ -100,13 +100,13 @@ func (h *jsonHook) applyingHeartbeat(progress applyProgress) {
func (h *jsonHook) PostApply(id terraform.HookResourceIdentity, dk addrs.DeposedKey, newState cty.Value, err error) (terraform.HookAction, error) {
key := id.Addr.String()
h.applyingLock.Lock()
progress := h.applying[key]
h.resourceProgressMu.Lock()
progress := h.resourceProgress[key]
if progress.done != nil {
close(progress.done)
}
delete(h.applying, key)
h.applyingLock.Unlock()
delete(h.resourceProgress, key)
h.resourceProgressMu.Unlock()
if progress.action == plans.NoOp {
return terraform.HookActionContinue, nil
@ -165,3 +165,62 @@ func (h *jsonHook) PostRefresh(id terraform.HookResourceIdentity, dk addrs.Depos
h.view.Hook(json.NewRefreshComplete(id.Addr, idKey, idValue))
return terraform.HookActionContinue, nil
}
func (h *jsonHook) PreEphemeralOp(id terraform.HookResourceIdentity, action plans.Action) (terraform.HookAction, error) {
h.view.Hook(json.NewEphemeralOpStart(id.Addr, action))
progress := resourceProgress{
addr: id.Addr,
action: action,
start: h.timeNow().Round(time.Second),
done: make(chan struct{}),
heartbeatDone: make(chan struct{}),
}
h.resourceProgressMu.Lock()
h.resourceProgress[id.Addr.String()] = progress
h.resourceProgressMu.Unlock()
go h.ephemeralOpHeartbeat(progress)
return terraform.HookActionContinue, nil
}
func (h *jsonHook) ephemeralOpHeartbeat(progress resourceProgress) {
defer close(progress.heartbeatDone)
for {
select {
case <-progress.done:
return
case <-h.timeAfter(h.periodicUiTimer):
}
elapsed := h.timeNow().Round(time.Second).Sub(progress.start)
h.view.Hook(json.NewEphemeralOpProgress(progress.addr, progress.action, elapsed))
}
}
func (h *jsonHook) PostEphemeralOp(id terraform.HookResourceIdentity, action plans.Action, opErr error) (terraform.HookAction, error) {
key := id.Addr.String()
h.resourceProgressMu.Lock()
progress := h.resourceProgress[key]
if progress.done != nil {
close(progress.done)
}
delete(h.resourceProgress, key)
h.resourceProgressMu.Unlock()
if progress.action == plans.NoOp {
return terraform.HookActionContinue, nil
}
elapsed := h.timeNow().Round(time.Second).Sub(progress.start)
if opErr != nil {
// Errors are collected and displayed post-operation, so no need to
// re-render them here. Instead just signal that this operation failed.
h.view.Hook(json.NewEphemeralOpErrored(id.Addr, progress.action, elapsed))
} else {
h.view.Hook(json.NewEphemeralOpComplete(id.Addr, progress.action, elapsed))
}
return terraform.HookActionContinue, nil
}

@ -4,6 +4,7 @@
package views
import (
"errors"
"fmt"
"sync"
"testing"
@ -96,13 +97,13 @@ func TestJSONHook_create(t *testing.T) {
testHookReturnValues(t, action, err)
// Shut down the progress goroutine if still active
hook.applyingLock.Lock()
for key, progress := range hook.applying {
hook.resourceProgressMu.Lock()
for key, progress := range hook.resourceProgress {
close(progress.done)
<-progress.heartbeatDone
delete(hook.applying, key)
delete(hook.resourceProgress, key)
}
hook.applyingLock.Unlock()
hook.resourceProgressMu.Unlock()
wantResource := map[string]interface{}{
"addr": string("test_instance.boop"),
@ -227,13 +228,13 @@ func TestJSONHook_errors(t *testing.T) {
testHookReturnValues(t, action, err)
// Shut down the progress goroutine
hook.applyingLock.Lock()
for key, progress := range hook.applying {
hook.resourceProgressMu.Lock()
for key, progress := range hook.resourceProgress {
close(progress.done)
<-progress.heartbeatDone
delete(hook.applying, key)
delete(hook.resourceProgress, key)
}
hook.applyingLock.Unlock()
hook.resourceProgressMu.Unlock()
wantResource := map[string]interface{}{
"addr": string("test_instance.boop"),
@ -340,6 +341,243 @@ func TestJSONHook_refresh(t *testing.T) {
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) {
streams, done := terminal.StreamsForTesting(t)
hook := newJSONHook(NewJSONView(NewView(streams)))
hook.periodicUiTimer = 1 * time.Second
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(3100 * 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: Still opening... [3s elapsed]",
"@module": "terraform.ui",
"type": "ephemeral_op_progress",
"hook": map[string]interface{}{
"action": string("open"),
"elapsed_seconds": float64(3),
"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 3s",
"@module": "terraform.ui",
"type": "ephemeral_op_complete",
"hook": map[string]interface{}{
"action": string("open"),
"elapsed_seconds": float64(3),
"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_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 testHookReturnValues(t *testing.T, action terraform.HookAction, err error) {
t.Helper()

@ -21,6 +21,7 @@ import (
"github.com/hashicorp/terraform/internal/terraform"
)
// How long to wait between sending heartbeat/progress messages
const defaultPeriodicUiTimer = 10 * time.Second
const maxIdLen = 80
@ -48,10 +49,15 @@ var _ terraform.Hook = (*UiHook)(nil)
// uiResourceState tracks the state of a single resource
type uiResourceState struct {
DispAddr string
IDKey, IDValue string
Op uiResourceOp
Start time.Time
// Address represents resource address
Address string
// IDKey represents name of the identifyable attribute (e.g. "id" or "name")
IDKey string
// IDValue represents the ID
IDValue string
Op uiResourceOp
Start time.Time
DoneCh chan struct{} // To be used for cancellation
@ -68,6 +74,9 @@ const (
uiResourceDestroy
uiResourceRead
uiResourceNoOp
uiResourceOpen
uiResourceRenew
uiResourceClose
)
func (h *UiHook) PreApply(id terraform.HookResourceIdentity, dk addrs.DeposedKey, action plans.Action, priorState, plannedNewState cty.Value) (terraform.HookAction, error) {
@ -122,13 +131,13 @@ func (h *UiHook) PreApply(id terraform.HookResourceIdentity, dk addrs.DeposedKey
key := id.Addr.String()
uiState := uiResourceState{
DispAddr: key,
IDKey: idKey,
IDValue: idValue,
Op: op,
Start: time.Now().Round(time.Second),
DoneCh: make(chan struct{}),
done: make(chan struct{}),
Address: key,
IDKey: idKey,
IDValue: idValue,
Op: op,
Start: time.Now().Round(time.Second),
DoneCh: make(chan struct{}),
done: make(chan struct{}),
}
h.resourcesLock.Lock()
@ -137,13 +146,13 @@ func (h *UiHook) PreApply(id terraform.HookResourceIdentity, dk addrs.DeposedKey
// Start goroutine that shows progress
if op != uiResourceNoOp {
go h.stillApplying(uiState)
go h.stillRunning(uiState)
}
return terraform.HookActionContinue, nil
}
func (h *UiHook) stillApplying(state uiResourceState) {
func (h *UiHook) stillRunning(state uiResourceState) {
defer close(state.done)
for {
select {
@ -164,6 +173,12 @@ func (h *UiHook) stillApplying(state uiResourceState) {
msg = "Still creating..."
case uiResourceRead:
msg = "Still reading..."
case uiResourceOpen:
msg = "Still opening..."
case uiResourceRenew:
msg = "Still renewing..."
case uiResourceClose:
msg = "Still closing..."
case uiResourceUnknown:
return
}
@ -175,7 +190,7 @@ func (h *UiHook) stillApplying(state uiResourceState) {
h.println(fmt.Sprintf(
h.view.colorize.Color("[reset][bold]%s: %s [%s%s elapsed][reset]"),
state.DispAddr,
state.Address,
msg,
idSuffix,
time.Now().Round(time.Second).Sub(state.Start),
@ -330,6 +345,87 @@ func (h *UiHook) PostApplyImport(id terraform.HookResourceIdentity, importing pl
return terraform.HookActionContinue, nil
}
func (h *UiHook) PreEphemeralOp(rId terraform.HookResourceIdentity, action plans.Action) (terraform.HookAction, error) {
key := rId.Addr.String()
var operation string
var op uiResourceOp
switch action {
case plans.Open:
operation = "Opening..."
op = uiResourceOpen
case plans.Renew:
operation = "Renewing..."
op = uiResourceRenew
case plans.Close:
operation = "Closing..."
op = uiResourceClose
default:
// We don't expect any other actions in here, so anything else is a
// bug in the caller but we'll ignore it in order to be robust.
h.println(fmt.Sprintf("(Unknown action %s for %s)", action, key))
return terraform.HookActionContinue, nil
}
uiState := uiResourceState{
Address: key,
Op: op,
Start: time.Now().Round(time.Second),
DoneCh: make(chan struct{}),
done: make(chan struct{}),
}
h.resourcesLock.Lock()
h.resources[key] = uiState
h.resourcesLock.Unlock()
h.println(fmt.Sprintf(
h.view.colorize.Color("[reset][bold]%s: %s"),
rId.Addr, operation,
))
go h.stillRunning(uiState)
return terraform.HookActionContinue, nil
}
func (h *UiHook) PostEphemeralOp(rId terraform.HookResourceIdentity, action plans.Action, opErr error) (terraform.HookAction, error) {
addr := rId.Addr.String()
h.resourcesLock.Lock()
state := h.resources[addr]
if state.DoneCh != nil {
close(state.DoneCh)
}
delete(h.resources, addr)
h.resourcesLock.Unlock()
elapsedTime := time.Now().Round(time.Second).Sub(state.Start)
var msg string
switch state.Op {
case uiResourceOpen:
msg = "Opening complete"
case uiResourceRenew:
msg = "Renewal complete"
case uiResourceClose:
msg = "Closing complete"
case uiResourceUnknown:
return terraform.HookActionContinue, nil
}
if opErr != nil {
// Errors are collected and printed in ApplyCommand, no need to duplicate
return terraform.HookActionContinue, nil
}
h.println(fmt.Sprintf(
h.view.colorize.Color("[reset][bold]%s: %s after %s"),
rId.Addr, msg, elapsedTime,
))
return terraform.HookActionContinue, nil
}
// Wrap calls to the view so that concurrent calls do not interleave println.
func (h *UiHook) println(s string) {
h.viewLock.Lock()

@ -4,6 +4,7 @@
package views
import (
"errors"
"fmt"
"regexp"
"testing"
@ -562,6 +563,120 @@ func TestUiHookPostImportState(t *testing.T) {
}
}
func TestUiHookEphemeralOp(t *testing.T) {
streams, done := terminal.StreamsForTesting(t)
view := NewView(streams)
h := NewUiHook(view)
addr := addrs.Resource{
Mode: addrs.EphemeralResourceMode,
Type: "test_instance",
Name: "foo",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance)
action, err := h.PreEphemeralOp(testUiHookResourceID(addr), plans.Close)
if err != nil {
t.Fatal(err)
}
if action != terraform.HookActionContinue {
t.Fatalf("Expected hook to continue, given: %#v", action)
}
action, err = h.PostEphemeralOp(testUiHookResourceID(addr), plans.Close, nil)
if err != nil {
t.Fatal(err)
}
if action != terraform.HookActionContinue {
t.Fatalf("Expected hook to continue, given: %#v", action)
}
result := done(t)
want := `ephemeral.test_instance.foo: Closing...
ephemeral.test_instance.foo: Closing complete after 0s
`
if got := result.Stdout(); got != want {
t.Fatalf("unexpected output\n got: %q\nwant: %q", got, want)
}
}
func TestUiHookEphemeralOp_progress(t *testing.T) {
streams, done := terminal.StreamsForTesting(t)
view := NewView(streams)
h := NewUiHook(view)
h.periodicUiTimer = 1 * time.Second
addr := addrs.Resource{
Mode: addrs.EphemeralResourceMode,
Type: "test_instance",
Name: "foo",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance)
action, err := h.PreEphemeralOp(testUiHookResourceID(addr), plans.Open)
if err != nil {
t.Fatal(err)
}
if action != terraform.HookActionContinue {
t.Fatalf("Expected hook to continue, given: %#v", action)
}
time.Sleep(3100 * time.Millisecond)
action, err = h.PostEphemeralOp(testUiHookResourceID(addr), plans.Open, nil)
if err != nil {
t.Fatal(err)
}
if action != terraform.HookActionContinue {
t.Fatalf("Expected hook to continue, given: %#v", action)
}
result := done(t)
want := `ephemeral.test_instance.foo: Opening...
ephemeral.test_instance.foo: Still opening... [1s elapsed]
ephemeral.test_instance.foo: Still opening... [2s elapsed]
ephemeral.test_instance.foo: Still opening... [3s elapsed]
ephemeral.test_instance.foo: Opening complete after 3s
`
if got := result.Stdout(); got != want {
t.Fatalf("unexpected output\n got: %q\nwant: %q", got, want)
}
}
func TestUiHookEphemeralOp_error(t *testing.T) {
streams, done := terminal.StreamsForTesting(t)
view := NewView(streams)
h := NewUiHook(view)
addr := addrs.Resource{
Mode: addrs.EphemeralResourceMode,
Type: "test_instance",
Name: "foo",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance)
action, err := h.PreEphemeralOp(testUiHookResourceID(addr), plans.Close)
if err != nil {
t.Fatal(err)
}
if action != terraform.HookActionContinue {
t.Fatalf("Expected hook to continue, given: %#v", action)
}
action, err = h.PostEphemeralOp(testUiHookResourceID(addr), plans.Close, errors.New("test error"))
if err != nil {
t.Fatal(err)
}
if action != terraform.HookActionContinue {
t.Fatalf("Expected hook to continue, given: %#v", action)
}
result := done(t)
want := `ephemeral.test_instance.foo: Closing...
`
if got := result.Stdout(); got != want {
t.Fatalf("unexpected output\n got: %q\nwant: %q", got, want)
}
}
func TestTruncateId(t *testing.T) {
testCases := []struct {
Input string

@ -71,6 +71,13 @@ const (
ActionReplace ChangeAction = "replace"
ActionDelete ChangeAction = "delete"
ActionImport ChangeAction = "import"
// While ephemeral resources do not represent a change
// or participate in the plan in the same way as the above
// we declare them here for convenience in helper functions.
ActionOpen ChangeAction = "open"
ActionRenew ChangeAction = "renew"
ActionClose ChangeAction = "close"
)
func changeAction(action plans.Action) ChangeAction {
@ -89,6 +96,12 @@ func changeAction(action plans.Action) ChangeAction {
return ActionDelete
case plans.Forget:
return ActionForget
case plans.Open:
return ActionOpen
case plans.Renew:
return ActionRenew
case plans.Close:
return ActionClose
default:
return ActionNoOp
}

@ -16,22 +16,23 @@ type Hook interface {
String() string
}
// ApplyStart: triggered by PreApply hook
type applyStart struct {
// operationStart: triggered by Pre{Apply,EphemeralOp} hook
type operationStart struct {
Resource ResourceAddr `json:"resource"`
Action ChangeAction `json:"action"`
IDKey string `json:"id_key,omitempty"`
IDValue string `json:"id_value,omitempty"`
actionVerb string
msgType MessageType
}
var _ Hook = (*applyStart)(nil)
var _ Hook = (*operationStart)(nil)
func (h *applyStart) HookType() MessageType {
return MessageApplyStart
func (h *operationStart) HookType() MessageType {
return h.msgType
}
func (h *applyStart) String() string {
func (h *operationStart) String() string {
var id string
if h.IDKey != "" && h.IDValue != "" {
id = fmt.Sprintf(" [%s=%s]", h.IDKey, h.IDValue)
@ -40,49 +41,74 @@ func (h *applyStart) String() string {
}
func NewApplyStart(addr addrs.AbsResourceInstance, action plans.Action, idKey string, idValue string) Hook {
hook := &applyStart{
hook := &operationStart{
Resource: newResourceAddr(addr),
Action: changeAction(action),
IDKey: idKey,
IDValue: idValue,
actionVerb: startActionVerb(action),
msgType: MessageApplyStart,
}
return hook
}
// ApplyProgress: currently triggered by a timer started on PreApply. In
func NewEphemeralOpStart(addr addrs.AbsResourceInstance, action plans.Action) Hook {
hook := &operationStart{
Resource: newResourceAddr(addr),
Action: changeAction(action),
actionVerb: startActionVerb(action),
msgType: MessageEphemeralOpStart,
}
return hook
}
// operationProgress: currently triggered by a timer started on Pre{Apply,EphemeralOp}. In
// future, this might also be triggered by provider progress reporting.
type applyProgress struct {
type operationProgress struct {
Resource ResourceAddr `json:"resource"`
Action ChangeAction `json:"action"`
Elapsed float64 `json:"elapsed_seconds"`
actionVerb string
elapsed time.Duration
msgType MessageType
}
var _ Hook = (*applyProgress)(nil)
var _ Hook = (*operationProgress)(nil)
func (h *applyProgress) HookType() MessageType {
return MessageApplyProgress
func (h *operationProgress) HookType() MessageType {
return h.msgType
}
func (h *applyProgress) String() string {
func (h *operationProgress) String() string {
return fmt.Sprintf("%s: Still %s... [%s elapsed]", h.Resource.Addr, h.actionVerb, h.elapsed)
}
func NewApplyProgress(addr addrs.AbsResourceInstance, action plans.Action, elapsed time.Duration) Hook {
return &applyProgress{
return &operationProgress{
Resource: newResourceAddr(addr),
Action: changeAction(action),
Elapsed: elapsed.Seconds(),
actionVerb: progressActionVerb(action),
elapsed: elapsed,
msgType: MessageApplyProgress,
}
}
func NewEphemeralOpProgress(addr addrs.AbsResourceInstance, action plans.Action, elapsed time.Duration) Hook {
return &operationProgress{
Resource: newResourceAddr(addr),
Action: changeAction(action),
Elapsed: elapsed.Seconds(),
actionVerb: progressActionVerb(action),
elapsed: elapsed,
msgType: MessageEphemeralOpProgress,
}
}
// ApplyComplete: triggered by PostApply hook
type applyComplete struct {
// operationComplete: triggered by PostApply hook
type operationComplete struct {
Resource ResourceAddr `json:"resource"`
Action ChangeAction `json:"action"`
IDKey string `json:"id_key,omitempty"`
@ -90,15 +116,16 @@ type applyComplete struct {
Elapsed float64 `json:"elapsed_seconds"`
actionNoun string
elapsed time.Duration
msgType MessageType
}
var _ Hook = (*applyComplete)(nil)
var _ Hook = (*operationComplete)(nil)
func (h *applyComplete) HookType() MessageType {
return MessageApplyComplete
func (h *operationComplete) HookType() MessageType {
return h.msgType
}
func (h *applyComplete) String() string {
func (h *operationComplete) String() string {
var id string
if h.IDKey != "" && h.IDValue != "" {
id = fmt.Sprintf(" [%s=%s]", h.IDKey, h.IDValue)
@ -107,7 +134,7 @@ func (h *applyComplete) String() string {
}
func NewApplyComplete(addr addrs.AbsResourceInstance, action plans.Action, idKey, idValue string, elapsed time.Duration) Hook {
return &applyComplete{
return &operationComplete{
Resource: newResourceAddr(addr),
Action: changeAction(action),
IDKey: idKey,
@ -115,36 +142,61 @@ func NewApplyComplete(addr addrs.AbsResourceInstance, action plans.Action, idKey
Elapsed: elapsed.Seconds(),
actionNoun: actionNoun(action),
elapsed: elapsed,
msgType: MessageApplyComplete,
}
}
// ApplyErrored: triggered by PostApply hook on failure. This will be followed
func NewEphemeralOpComplete(addr addrs.AbsResourceInstance, action plans.Action, elapsed time.Duration) Hook {
return &operationComplete{
Resource: newResourceAddr(addr),
Action: changeAction(action),
Elapsed: elapsed.Seconds(),
actionNoun: actionNoun(action),
elapsed: elapsed,
msgType: MessageEphemeralOpComplete,
}
}
// operationErrored: triggered by PostApply hook on failure. This will be followed
// by diagnostics when the apply finishes.
type applyErrored struct {
type operationErrored struct {
Resource ResourceAddr `json:"resource"`
Action ChangeAction `json:"action"`
Elapsed float64 `json:"elapsed_seconds"`
actionNoun string
elapsed time.Duration
msgType MessageType
}
var _ Hook = (*applyErrored)(nil)
var _ Hook = (*operationErrored)(nil)
func (h *applyErrored) HookType() MessageType {
return MessageApplyErrored
func (h *operationErrored) HookType() MessageType {
return h.msgType
}
func (h *applyErrored) String() string {
func (h *operationErrored) String() string {
return fmt.Sprintf("%s: %s errored after %s", h.Resource.Addr, h.actionNoun, h.elapsed)
}
func NewApplyErrored(addr addrs.AbsResourceInstance, action plans.Action, elapsed time.Duration) Hook {
return &applyErrored{
return &operationErrored{
Resource: newResourceAddr(addr),
Action: changeAction(action),
Elapsed: elapsed.Seconds(),
actionNoun: actionNoun(action),
elapsed: elapsed,
msgType: MessageApplyErrored,
}
}
func NewEphemeralOpErrored(addr addrs.AbsResourceInstance, action plans.Action, elapsed time.Duration) Hook {
return &operationErrored{
Resource: newResourceAddr(addr),
Action: changeAction(action),
Elapsed: elapsed.Seconds(),
actionNoun: actionNoun(action),
elapsed: elapsed,
msgType: MessageEphemeralOpErrored,
}
}
@ -319,6 +371,12 @@ func startActionVerb(action plans.Action) string {
return "Replacing"
case plans.Forget:
return "Removing"
case plans.Open:
return "Opening"
case plans.Renew:
return "Renewing"
case plans.Close:
return "Closing"
case plans.NoOp:
// This should never be possible: a no-op planned change should not
// be applied. We'll fall back to "Applying".
@ -345,6 +403,12 @@ func progressActionVerb(action plans.Action) string {
// This is not currently possible to reach, as we receive separate
// passes for create and delete
return "replacing"
case plans.Open:
return "opening"
case plans.Renew:
return "renewing"
case plans.Close:
return "closing"
case plans.Forget:
// Removing a resource from state should not take very long. Fall back
// to "applying" just in case, since the terminology "forgetting" is
@ -360,7 +424,7 @@ func progressActionVerb(action plans.Action) string {
}
// Convert the subset of plans.Action values we expect to receive into a
// noun for the applyComplete and applyErrored hook messages. This will be
// noun for the operationComplete and operationErrored hook messages. This will be
// combined into a phrase like "Creation complete after 1m4s".
func actionNoun(action plans.Action) string {
switch action {
@ -378,6 +442,12 @@ func actionNoun(action plans.Action) string {
return "Replacement"
case plans.Forget:
return "Removal"
case plans.Open:
return "Opening"
case plans.Renew:
return "Renewal"
case plans.Close:
return "Closing"
case plans.NoOp:
// This should never be possible: a no-op planned change should not
// be applied. We'll fall back to "Apply".

@ -29,6 +29,12 @@ const (
MessageRefreshStart MessageType = "refresh_start"
MessageRefreshComplete MessageType = "refresh_complete"
// Ephemeral operation messages
MessageEphemeralOpStart MessageType = "ephemeral_op_start"
MessageEphemeralOpProgress MessageType = "ephemeral_op_progress"
MessageEphemeralOpComplete MessageType = "ephemeral_op_complete"
MessageEphemeralOpErrored MessageType = "ephemeral_op_errored"
// Test messages
MessageTestAbstract MessageType = "test_abstract"
MessageTestFile MessageType = "test_file"

@ -15,6 +15,9 @@ const (
Delete Action = '-'
Forget Action = '.'
CreateThenForget Action = '⨥'
Open Action = '⟃'
Renew Action = '⟳'
Close Action = '⫏'
)
//go:generate go run golang.org/x/tools/cmd/stringer -type Action

@ -17,43 +17,31 @@ func _() {
_ = x[Delete-45]
_ = x[Forget-46]
_ = x[CreateThenForget-10789]
_ = x[Open-10179]
_ = x[Renew-10227]
_ = x[Close-10959]
}
const (
_Action_name_0 = "NoOp"
_Action_name_1 = "Create"
_Action_name_2 = "DeleteForget"
_Action_name_3 = "Update"
_Action_name_4 = "CreateThenDelete"
_Action_name_5 = "Read"
_Action_name_6 = "DeleteThenCreate"
_Action_name_7 = "CreateThenForget"
)
const _Action_name = "NoOpCreateDeleteForgetUpdateCreateThenDeleteReadDeleteThenCreateOpenRenewCreateThenForgetClose"
var (
_Action_index_2 = [...]uint8{0, 6, 12}
)
var _Action_map = map[Action]string{
0: _Action_name[0:4],
43: _Action_name[4:10],
45: _Action_name[10:16],
46: _Action_name[16:22],
126: _Action_name[22:28],
177: _Action_name[28:44],
8592: _Action_name[44:48],
8723: _Action_name[48:64],
10179: _Action_name[64:68],
10227: _Action_name[68:73],
10789: _Action_name[73:89],
10959: _Action_name[89:94],
}
func (i Action) String() string {
switch {
case i == 0:
return _Action_name_0
case i == 43:
return _Action_name_1
case 45 <= i && i <= 46:
i -= 45
return _Action_name_2[_Action_index_2[i]:_Action_index_2[i+1]]
case i == 126:
return _Action_name_3
case i == 177:
return _Action_name_4
case i == 8592:
return _Action_name_5
case i == 8723:
return _Action_name_6
case i == 10789:
return _Action_name_7
default:
return "Action(" + strconv.FormatInt(int64(i), 10) + ")"
if str, ok := _Action_map[i]; ok {
return str
}
return "Action(" + strconv.FormatInt(int64(i), 10) + ")"
}

@ -28,6 +28,8 @@ import (
"github.com/hashicorp/terraform/internal/tfdiags"
)
type hookFunc func(func(Hook) (HookAction, error)) error
// EvalContext is the interface that is given to eval nodes to execute.
type EvalContext interface {
// Stopped returns a context that is canceled when evaluation is stopped via

@ -98,6 +98,11 @@ type Hook interface {
PreApplyImport(id HookResourceIdentity, importing plans.ImportingSrc) (HookAction, error)
PostApplyImport(id HookResourceIdentity, importing plans.ImportingSrc) (HookAction, error)
// PreEphemeralOp and PostEphemeralOp are called during an operation on ephemeral resource
// such as opening, renewal or closing
PreEphemeralOp(id HookResourceIdentity, action plans.Action) (HookAction, error)
PostEphemeralOp(id HookResourceIdentity, action plans.Action, opErr error) (HookAction, error)
// Stopping is called if an external signal requests that Terraform
// gracefully abort an operation in progress.
//
@ -196,6 +201,14 @@ func (h *NilHook) PostApplyImport(id HookResourceIdentity, importing plans.Impor
return HookActionContinue, nil
}
func (h *NilHook) PreEphemeralOp(id HookResourceIdentity, action plans.Action) (HookAction, error) {
return HookActionContinue, nil
}
func (h *NilHook) PostEphemeralOp(id HookResourceIdentity, action plans.Action, opErr error) (HookAction, error) {
return HookActionContinue, nil
}
func (*NilHook) Stopping() {
// Does nothing at all by default
}

@ -131,6 +131,17 @@ type MockHook struct {
PostApplyImportReturn HookAction
PostApplyImportError error
PreEphemeralOpCalled bool
PreEphemeralOpAddr addrs.AbsResourceInstance
PreEphemeralOpReturn HookAction
PreEphemeralOpReturnError error
PostEphemeralOpCalled bool
PostEphemeralOpAddr addrs.AbsResourceInstance
PostEphemeralOpError error
PostEphemeralOpReturn HookAction
PostEphemeralOpReturnError error
StoppingCalled bool
PostStateUpdateCalled bool
@ -316,6 +327,25 @@ func (h *MockHook) PostApplyImport(id HookResourceIdentity, importing plans.Impo
return h.PostApplyImportReturn, h.PostApplyImportError
}
func (h *MockHook) PreEphemeralOp(id HookResourceIdentity, action plans.Action) (HookAction, error) {
h.Lock()
defer h.Unlock()
h.PreEphemeralOpCalled = true
h.PreEphemeralOpAddr = id.Addr
return h.PreEphemeralOpReturn, h.PreEphemeralOpReturnError
}
func (h *MockHook) PostEphemeralOp(id HookResourceIdentity, action plans.Action, opErr error) (HookAction, error) {
h.Lock()
defer h.Unlock()
h.PostEphemeralOpCalled = true
h.PostEphemeralOpAddr = id.Addr
h.PostEphemeralOpError = opErr
return h.PostEphemeralOpReturn, h.PostEphemeralOpReturnError
}
func (h *MockHook) Stopping() {
h.Lock()
defer h.Unlock()

@ -90,6 +90,14 @@ func (h *stopHook) PostApplyImport(id HookResourceIdentity, importing plans.Impo
return h.hook()
}
func (h *stopHook) PreEphemeralOp(id HookResourceIdentity, action plans.Action) (HookAction, error) {
return h.hook()
}
func (h *stopHook) PostEphemeralOp(id HookResourceIdentity, action plans.Action, opErr error) (HookAction, error) {
return h.hook()
}
func (h *stopHook) Stopping() {}
func (h *stopHook) PostStateUpdate(new *states.State) (HookAction, error) {

@ -155,6 +155,20 @@ func (h *testHook) PostApplyImport(id HookResourceIdentity, importing plans.Impo
return HookActionContinue, nil
}
func (h *testHook) PreEphemeralOp(id HookResourceIdentity, action plans.Action) (HookAction, error) {
h.mu.Lock()
defer h.mu.Unlock()
h.Calls = append(h.Calls, &testHookCall{"PreEphemeralOp", id.Addr.String()})
return HookActionContinue, nil
}
func (h *testHook) PostEphemeralOp(id HookResourceIdentity, action plans.Action, err error) (HookAction, error) {
h.mu.Lock()
defer h.mu.Unlock()
h.Calls = append(h.Calls, &testHookCall{"PostEphemeralOp", id.Addr.String()})
return HookActionContinue, nil
}
func (h *testHook) Stopping() {
h.mu.Lock()
defer h.mu.Unlock()

@ -13,6 +13,7 @@ import (
"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/configs"
"github.com/hashicorp/terraform/internal/lang/marks"
"github.com/hashicorp/terraform/internal/plans"
"github.com/hashicorp/terraform/internal/plans/objchange"
"github.com/hashicorp/terraform/internal/providers"
"github.com/hashicorp/terraform/internal/resources/ephemeral"
@ -82,10 +83,21 @@ func ephemeralResourceOpen(ctx EvalContext, inp ephemeralResourceInput) (*provid
return nil, diags
}
rId := HookResourceIdentity{
Addr: inp.addr,
ProviderAddr: inp.providerConfig.Provider,
}
ctx.Hook(func(h Hook) (HookAction, error) {
return h.PreEphemeralOp(rId, plans.Open)
})
resp := provider.OpenEphemeralResource(providers.OpenEphemeralResourceRequest{
TypeName: inp.addr.ContainingResource().Resource.Type,
Config: unmarkedConfigVal,
})
ctx.Hook(func(h Hook) (HookAction, error) {
return h.PostEphemeralOp(rId, plans.Open, resp.Diagnostics.Err())
})
diags = diags.Append(resp.Diagnostics.InConfigBody(config.Config, inp.addr.String()))
if diags.HasErrors() {
return nil, diags
@ -119,9 +131,11 @@ func ephemeralResourceOpen(ctx EvalContext, inp ephemeralResourceInput) (*provid
resultVal = resultVal.Mark(marks.Ephemeral)
impl := &ephemeralResourceInstImpl{
addr: inp.addr,
provider: provider,
internal: resp.Private,
addr: inp.addr,
providerCfg: inp.providerConfig,
provider: provider,
hook: ctx.Hook,
internal: resp.Private,
}
ephemerals.RegisterInstance(ctx.StopCtx(), inp.addr, ephemeral.ResourceInstanceRegistration{
@ -203,9 +217,11 @@ func (n *nodeEphemeralResourceClose) SetProvider(provider addrs.AbsProviderConfi
// ephemeralResourceInstImpl implements ephemeral.ResourceInstance as an
// adapter to the relevant provider API calls.
type ephemeralResourceInstImpl struct {
addr addrs.AbsResourceInstance
provider providers.Interface
internal []byte
addr addrs.AbsResourceInstance
providerCfg addrs.AbsProviderConfig
provider providers.Interface
hook hookFunc
internal []byte
}
var _ ephemeral.ResourceInstance = (*ephemeralResourceInstImpl)(nil)
@ -213,11 +229,20 @@ var _ ephemeral.ResourceInstance = (*ephemeralResourceInstImpl)(nil)
// Close implements ephemeral.ResourceInstance.
func (impl *ephemeralResourceInstImpl) Close(ctx context.Context) tfdiags.Diagnostics {
log.Printf("[TRACE] ephemeralResourceInstImpl: closing %s", impl.addr)
rId := HookResourceIdentity{
Addr: impl.addr,
ProviderAddr: impl.providerCfg.Provider,
}
impl.hook(func(h Hook) (HookAction, error) {
return h.PreEphemeralOp(rId, plans.Close)
})
resp := impl.provider.CloseEphemeralResource(providers.CloseEphemeralResourceRequest{
TypeName: impl.addr.Resource.Resource.Type,
Private: impl.internal,
})
impl.hook(func(h Hook) (HookAction, error) {
return h.PostEphemeralOp(rId, plans.Close, resp.Diagnostics.Err())
})
return resp.Diagnostics
}
@ -225,11 +250,20 @@ func (impl *ephemeralResourceInstImpl) Close(ctx context.Context) tfdiags.Diagno
func (impl *ephemeralResourceInstImpl) Renew(ctx context.Context, req providers.EphemeralRenew) (nextRenew *providers.EphemeralRenew, diags tfdiags.Diagnostics) {
log.Printf("[TRACE] ephemeralResourceInstImpl: renewing %s", impl.addr)
rId := HookResourceIdentity{
Addr: impl.addr,
ProviderAddr: impl.providerCfg.Provider,
}
impl.hook(func(h Hook) (HookAction, error) {
return h.PreEphemeralOp(rId, plans.Renew)
})
resp := impl.provider.RenewEphemeralResource(providers.RenewEphemeralResourceRequest{
TypeName: impl.addr.Resource.Resource.Type,
Private: req.Private,
})
impl.hook(func(h Hook) (HookAction, error) {
return h.PostEphemeralOp(rId, plans.Renew, resp.Diagnostics.Err())
})
if !resp.RenewAt.IsZero() {
nextRenew = &providers.EphemeralRenew{
RenewAt: resp.RenewAt,

@ -254,6 +254,10 @@ Performing Terraform operations to a resource will often result in several messa
- `provision_errored`: when an error is enountered during provisioning
- `refresh_start`: when reading a resource during refresh
- `refresh_complete`: on successful refresh
- `ephemeral_op_start`: when starting an ephemeral resource operation
- `ephemeral_op_progress`: periodically showing elapsed time output during ephemeral resource operation
- `ephemeral_op_complete`: on successful ephemeral resource operation completion
- `ephemeral_op_errored`: when an error is encountered during ephemeral resource operation
Each of these messages has a `hook` object, which has different fields for each type. All hooks have a [`resource` object](#resource-object) which identifies which resource is the subject of the operation.
@ -585,6 +589,138 @@ The `refresh_complete` message `hook` object has the following keys:
}
```
## Ephemeral Operation Start
The `ephemeral_op_start` message `hook` object has the following keys:
- `resource`: a [`resource` object](#resource-object) identifying the resource
- `action`: the action the ephemeral resource is going through. Values: `open`, `renew`, `close`
### Example
```json
{
"@level": "info",
"@message": "ephemeral.random_password.example: Opening...",
"@module": "terraform.ui",
"@timestamp": "2024-10-30T10:34:26.222465-00:00",
"hook": {
"resource": {
"addr": "ephemeral.random_password.example",
"module": "",
"resource": "ephemeral.random_password.example",
"implied_provider": "random",
"resource_type": "random_password",
"resource_name": "example",
"resource_key": null
},
"action": "open"
},
"type": "ephemeral_op_start"
}
```
## Ephemeral Operation Progress
The `ephemeral_op_progress` message `hook` object has the following keys:
- `resource`: a [`resource` object](#resource-object) identifying the resource
- `action`: the action the ephemeral resource is going through. Values: `open`, `renew`, `close`
- `elapsed_seconds`: time elapsed since the operation started, expressed as an integer number of seconds
### Example
```json
{
"@level": "info",
"@message": "ephemeral.random_password.example: Closing... [3s elapsed]",
"@module": "terraform.ui",
"@timestamp": "2024-10-30T10:34:26.222465-00:00",
"hook": {
"resource": {
"addr": "ephemeral.random_password.example",
"module": "",
"resource": "ephemeral.random_password.example",
"implied_provider": "random",
"resource_type": "random_password",
"resource_name": "example",
"resource_key": null
},
"action": "close",
"elapsed_seconds": 3
},
"type": "ephemeral_op_progress"
}
```
## Ephemeral Operation Complete
The `ephemeral_op_start` message `hook` object has the following keys:
- `resource`: a [`resource` object](#resource-object) identifying the resource
- `action`: the action the ephemeral resource is going through. Values: `open`, `renew`, `close`
- `elapsed_seconds`: time elapsed since the operation started, expressed as an integer number of seconds
### Example
```json
{
"@level": "info",
"@message": "ephemeral.random_password.example: Opening complete after 1s",
"@module": "terraform.ui",
"@timestamp": "2024-10-30T10:34:26.222465-00:00",
"hook": {
"resource": {
"addr": "ephemeral.random_password.example",
"module": "",
"resource": "ephemeral.random_password.example",
"implied_provider": "random",
"resource_type": "random_password",
"resource_name": "example",
"resource_key": null
},
"action": "open",
"elapsed_seconds": 1
},
"type": "ephemeral_op_complete"
}
```
## Ephemeral Operation Errored
The `ephemeral_op_start` message `hook` object has the following keys:
- `resource`: a [`resource` object](#resource-object) identifying the resource
- `action`: the action the ephemeral resource is going through. Values: `open`, `renew`, `close`
- `elapsed_seconds`: time elapsed since the operation started, expressed as an integer number of seconds
The exact detail of the error will be rendered as a separate `diagnostic` message.
### Example
```json
{
"@level": "info",
"@message": "ephemeral.random_password.example: Opening errored after 2s",
"@module": "terraform.ui",
"@timestamp": "2024-10-30T10:34:26.222465-00:00",
"hook": {
"resource": {
"addr": "ephemeral.random_password.example",
"module": "",
"resource": "ephemeral.random_password.example",
"implied_provider": "random",
"resource_type": "random_password",
"resource_name": "example",
"resource_key": null
},
"action": "open",
"elapsed_seconds": 2
},
"type": "ephemeral_op_errored"
}
```
## Resource Object
The `resource` object is a decomposed structure representing a resource address in configuration, which is used to identify which resource a given message is associated with. The object has the following keys:

Loading…
Cancel
Save