diff --git a/internal/command/jsonformat/renderer.go b/internal/command/jsonformat/renderer.go index 7e3c7164b8..a3463ac8c5 100644 --- a/internal/command/jsonformat/renderer.go +++ b/internal/command/jsonformat/renderer.go @@ -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) diff --git a/internal/command/views/hook_json.go b/internal/command/views/hook_json.go index 0d2858faad..9ac061f292 100644 --- a/internal/command/views/hook_json.go +++ b/internal/command/views/hook_json.go @@ -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 +} diff --git a/internal/command/views/hook_json_test.go b/internal/command/views/hook_json_test.go index ed026cde30..43102b15b2 100644 --- a/internal/command/views/hook_json_test.go +++ b/internal/command/views/hook_json_test.go @@ -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() diff --git a/internal/command/views/hook_ui.go b/internal/command/views/hook_ui.go index 79638f0d83..bc22260e23 100644 --- a/internal/command/views/hook_ui.go +++ b/internal/command/views/hook_ui.go @@ -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() diff --git a/internal/command/views/hook_ui_test.go b/internal/command/views/hook_ui_test.go index 51c876330b..708ac3f16a 100644 --- a/internal/command/views/hook_ui_test.go +++ b/internal/command/views/hook_ui_test.go @@ -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 diff --git a/internal/command/views/json/change.go b/internal/command/views/json/change.go index d01e5688b6..32c0f529e4 100644 --- a/internal/command/views/json/change.go +++ b/internal/command/views/json/change.go @@ -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 } diff --git a/internal/command/views/json/hook.go b/internal/command/views/json/hook.go index bd5a27b438..d4fc8aa036 100644 --- a/internal/command/views/json/hook.go +++ b/internal/command/views/json/hook.go @@ -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". diff --git a/internal/command/views/json/message_types.go b/internal/command/views/json/message_types.go index 159bed199a..3c3ec299bb 100644 --- a/internal/command/views/json/message_types.go +++ b/internal/command/views/json/message_types.go @@ -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" diff --git a/internal/plans/action.go b/internal/plans/action.go index 16af96965a..04dcca2dbf 100644 --- a/internal/plans/action.go +++ b/internal/plans/action.go @@ -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 diff --git a/internal/plans/action_string.go b/internal/plans/action_string.go index 827e67c5cb..35f6c13e69 100644 --- a/internal/plans/action_string.go +++ b/internal/plans/action_string.go @@ -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) + ")" } diff --git a/internal/terraform/eval_context.go b/internal/terraform/eval_context.go index e88e24f2c0..30ed37e928 100644 --- a/internal/terraform/eval_context.go +++ b/internal/terraform/eval_context.go @@ -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 diff --git a/internal/terraform/hook.go b/internal/terraform/hook.go index f7dbd6af04..c7e7b5c07a 100644 --- a/internal/terraform/hook.go +++ b/internal/terraform/hook.go @@ -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 } diff --git a/internal/terraform/hook_mock.go b/internal/terraform/hook_mock.go index 518212c838..dc55940b1b 100644 --- a/internal/terraform/hook_mock.go +++ b/internal/terraform/hook_mock.go @@ -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() diff --git a/internal/terraform/hook_stop.go b/internal/terraform/hook_stop.go index f3e5129046..e85ba27f09 100644 --- a/internal/terraform/hook_stop.go +++ b/internal/terraform/hook_stop.go @@ -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) { diff --git a/internal/terraform/hook_test.go b/internal/terraform/hook_test.go index 5727e7c746..5813d53e46 100644 --- a/internal/terraform/hook_test.go +++ b/internal/terraform/hook_test.go @@ -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() diff --git a/internal/terraform/node_resource_ephemeral.go b/internal/terraform/node_resource_ephemeral.go index bd5a8f36a3..b57de5ed26 100644 --- a/internal/terraform/node_resource_ephemeral.go +++ b/internal/terraform/node_resource_ephemeral.go @@ -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, diff --git a/website/docs/internals/machine-readable-ui.mdx b/website/docs/internals/machine-readable-ui.mdx index 0e17adb8b1..a2979246f9 100644 --- a/website/docs/internals/machine-readable-ui.mdx +++ b/website/docs/internals/machine-readable-ui.mdx @@ -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: