// Copyright IBM Corp. 2014, 2026 // SPDX-License-Identifier: BUSL-1.1 package views import ( "bufio" "fmt" "strings" "sync" "time" "unicode" "github.com/zclconf/go-cty/cty" "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/command/format" "github.com/hashicorp/terraform/internal/command/views/json" "github.com/hashicorp/terraform/internal/configs/configschema" "github.com/hashicorp/terraform/internal/genconfig" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/terraform" ) func newJSONHook(view *JSONView) *jsonHook { return &jsonHook{ view: view, resourceProgress: make(map[string]resourceProgress), timeNow: time.Now, timeAfter: time.After, periodicUiTimer: defaultPeriodicUiTimer, } } type jsonHook struct { terraform.NilHook view *JSONView // 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 resourceProgress struct { addr addrs.AbsResourceInstance action plans.Action start time.Time // 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 // goroutine to finish heartbeatDone chan struct{} } func (h *jsonHook) PreApply(id terraform.HookResourceIdentity, dk addrs.DeposedKey, action plans.Action, priorState, plannedNewState cty.Value) (terraform.HookAction, error) { if action != plans.NoOp { idKey, idValue := format.ObjectValueIDOrName(priorState) h.view.Hook(json.NewApplyStart(id.Addr, action, idKey, idValue)) } 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() if action != plans.NoOp { go h.applyingHeartbeat(progress) } return terraform.HookActionContinue, nil } func (h *jsonHook) applyingHeartbeat(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.NewApplyProgress(progress.addr, progress.action, elapsed)) } } func (h *jsonHook) PostApply(id terraform.HookResourceIdentity, dk addrs.DeposedKey, newState cty.Value, err 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 err != nil { // Errors are collected and displayed post-apply, so no need to // re-render them here. Instead just signal that this resource failed // to apply. h.view.Hook(json.NewApplyErrored(id.Addr, progress.action, elapsed)) } else { idKey, idValue := format.ObjectValueID(newState) h.view.Hook(json.NewApplyComplete(id.Addr, progress.action, idKey, idValue, elapsed)) } return terraform.HookActionContinue, nil } func (h *jsonHook) PreProvisionInstanceStep(id terraform.HookResourceIdentity, typeName string) (terraform.HookAction, error) { h.view.Hook(json.NewProvisionStart(id.Addr, typeName)) return terraform.HookActionContinue, nil } func (h *jsonHook) PostProvisionInstanceStep(id terraform.HookResourceIdentity, typeName string, err error) (terraform.HookAction, error) { if err != nil { // Errors are collected and displayed post-apply, so no need to // re-render them here. Instead just signal that this provisioner step // failed. h.view.Hook(json.NewProvisionErrored(id.Addr, typeName)) } else { h.view.Hook(json.NewProvisionComplete(id.Addr, typeName)) } return terraform.HookActionContinue, nil } func (h *jsonHook) ProvisionOutput(id terraform.HookResourceIdentity, typeName string, msg string) { s := bufio.NewScanner(strings.NewReader(msg)) s.Split(scanLines) for s.Scan() { line := strings.TrimRightFunc(s.Text(), unicode.IsSpace) if line != "" { h.view.Hook(json.NewProvisionProgress(id.Addr, typeName, line)) } } } func (h *jsonHook) PreRefresh(id terraform.HookResourceIdentity, dk addrs.DeposedKey, priorState cty.Value) (terraform.HookAction, error) { idKey, idValue := format.ObjectValueID(priorState) h.view.Hook(json.NewRefreshStart(id.Addr, idKey, idValue)) return terraform.HookActionContinue, nil } func (h *jsonHook) PostRefresh(id terraform.HookResourceIdentity, dk addrs.DeposedKey, priorState cty.Value, newState cty.Value) (terraform.HookAction, error) { idKey, idValue := format.ObjectValueID(newState) 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) { // this uses the same plans.Read action as a data source to indicate that // the ephemeral resource can't be processed until apply, so there is no // progress hook if action == plans.Read { return terraform.HookActionContinue, nil } 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 } func (h *jsonHook) PreListQuery(id terraform.HookResourceIdentity, input_config cty.Value, configSchema *configschema.Block) (terraform.HookAction, error) { addr := id.Addr h.view.log.Info( fmt.Sprintf("%s: Starting query...", addr.String()), "type", json.MessageListStart, json.MessageListStart, json.NewQueryStart(addr, input_config, configSchema), ) return terraform.HookActionContinue, nil } func (h *jsonHook) PostListQuery(id terraform.HookResourceIdentity, results plans.QueryResults, identityVersion int64) (terraform.HookAction, error) { addr := id.Addr data := results.Value.GetAttr("data") iter := data.ElementIterator() for idx := 0; iter.Next(); idx++ { _, value := iter.Element() var generated *genconfig.ResourceImport if len(results.Generated.Imports) > 0 { generated = &results.Generated.Imports[idx] } result := json.NewQueryResult(addr, value, identityVersion, generated) h.view.log.Info( fmt.Sprintf("%s: Result found", addr.String()), "type", json.MessageListResourceFound, json.MessageListResourceFound, result, ) } h.view.log.Info( fmt.Sprintf("%s: List complete", addr.String()), "type", json.MessageListComplete, json.MessageListComplete, json.NewQueryComplete(addr, data.LengthInt()), ) return terraform.HookActionContinue, nil } func (h *jsonHook) StartAction(id terraform.HookActionIdentity) (terraform.HookAction, error) { h.view.Hook(json.NewActionStart(id)) return terraform.HookActionContinue, nil } func (h *jsonHook) ProgressAction(id terraform.HookActionIdentity, progress string) (terraform.HookAction, error) { h.view.Hook(json.NewActionProgress(id, progress)) return terraform.HookActionContinue, nil } func (h *jsonHook) CompleteAction(id terraform.HookActionIdentity, err error) (terraform.HookAction, error) { if err != nil { h.view.Hook(json.NewActionErrored(id, err)) } else { h.view.Hook(json.NewActionComplete(id)) } return terraform.HookActionContinue, nil }