Module: Pangea::CLI::TofuEvents

Defined in:
lib/pangea/cli/tofu_events.rb

Overview

Parses and renders the NDJSON event stream from ‘tofu plan -json` / `tofu apply -json` / `tofu destroy -json`.

OpenTofu’s -json flag emits one JSON object per line. Each event has ‘@level`, `@message`, `@timestamp`, and `type` fields, plus type-specific payload. Parsing the stream structurally (instead of grep’ing human-readable output) lets us:

  • Filter deprecation warnings (inline_policy, etc.) by matching on diagnostic.summary, not by string regex over arbitrary output.

  • Classify errors as transient (eventual consistency, throttling, service unavailable) vs permanent, by inspecting diagnostic.detail.

  • Render a clean, Nord-themed human summary across plan / apply / destroy with per-resource change glyphs and elapsed timings.

  • Feed the event stream into higher-level orchestration (audit logs, retry decisions, telemetry) as a typed data structure.

When ‘PANGEA_VERBOSE=1` is set, operations.rb bypasses this module and runs tofu in pass-through mode for full debug output.

Defined Under Namespace

Classes: Collector, Event

Constant Summary collapse

TRANSIENT_ERROR_PATTERNS =

Diagnostic message patterns that indicate a transient failure —safe to retry. We match against both ‘summary` and `detail`.

[
  /NoSuchEntity/,          # IAM eventual consistency (role not yet visible)
  /InvalidClientTokenId/,  # IAM creds not yet propagated
  /Throttling/,            # API rate limit
  /RequestLimitExceeded/,  # API rate limit
  /RequestTimeout/,        # Transient timeout
  /InternalFailure/,       # AWS internal
  /ServiceUnavailable/,    # Service outage
].freeze
DROPPED_WARNING_PATTERNS =

Warning summaries to drop from human output. These are known, understood, and not actionable at the user level.

[
  /inline_policy is deprecated/,
  /Argument is deprecated/,
].freeze

Class Method Summary collapse

Class Method Details

.parse_line(line) ⇒ Object

Parse one NDJSON line. Returns an Event, or nil if the line is not valid JSON (tofu sometimes emits non-JSON prelude lines — notably the “Initializing the backend…” text during ‘tofu init` which doesn’t support -json).



207
208
209
210
211
212
# File 'lib/pangea/cli/tofu_events.rb', line 207

def parse_line(line)
  return nil if line.nil? || line.empty?
  Event.new(JSON.parse(line))
rescue JSON::ParserError
  nil
end

.render_human(event) ⇒ Object

Render a single event as a one-line human-readable string, or nil if the event is not user-facing (dropped warnings, internal logs). Output is Nord-themed via Pangea::CLI::Theme.



152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
# File 'lib/pangea/cli/tofu_events.rb', line 152

def render_human(event)
  return nil if event.dropped_warning?

  case event.type
  when 'version', 'log', 'change_summary',
       'refresh_start', 'refresh_complete', 'apply_progress',
       'outputs'
    # Omitted from output. `outputs` (the type event, not state
    # outputs) is redundant with the change_summary.
    nil
  when 'planned_change'
    action = event.change&.dig('action')
    addr = event.resource_address
    return nil unless action && addr
    "  #{Theme.action_glyph(action)} #{Theme.color(:resource, addr)}"
  when 'apply_start'
    addr = event.resource_address
    addr ? "  #{Theme.progress_glyph} #{Theme.color(:resource, addr)}" : nil
  when 'apply_complete'
    addr = event.resource_address
    addr ? "  #{Theme.success_glyph} #{Theme.color(:resource, addr)}" : nil
  when 'apply_errored'
    addr = event.resource_address
    err = event.hook&.dig('error')
    body = err ? "#{Theme.color(:resource, addr)}: #{Theme.color(:error, err)}" \
               : Theme.color(:resource, addr.to_s)
    "  #{Theme.error_glyph} #{body}"
  when 'diagnostic'
    d = event.diagnostic
    return nil if d.nil?
    severity = d['severity'].to_s
    role = case severity
           when 'error'   then :error
           when 'warning' then :warning
           else :info
           end
    prefix = Theme.color(role, "[#{severity.upcase}]")
    lines = ["#{prefix} #{Theme.color(:resource, d['summary'])}"]
    detail = d['detail'].to_s
    unless detail.empty?
      # Indent the detail with a subtle left-bar for visual grouping.
      detail.lines.each do |dl|
        lines << "  #{Theme.color(:divider, '')} #{Theme.color(role, dl.chomp)}"
      end
    end
    lines.join("\n")
  else
    event.message if event.level != 'info'
  end
end

.stream(cmd, args) ⇒ Object

Run ‘cmd` with args, streaming -json output. Yields each Event to the block if given, otherwise collects silently. Returns the Collector regardless. Exit status is available via $?.



217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
# File 'lib/pangea/cli/tofu_events.rb', line 217

def stream(cmd, args)
  collector = Collector.new
  IO.popen([cmd, *args], err: %i[child out]) do |io|
    io.each_line do |line|
      line = line.chomp
      event = parse_line(line)
      if event.nil?
        # Non-JSON passthrough (rare, but tofu init etc. may emit
        # human-readable text before the JSON stream begins).
        yield_raw(line) if block_given?
        next
      end
      collector.consume(event)
      yield event if block_given?
    end
  end
  collector
end

.yield_raw(line) ⇒ Object

Helper for yield_raw — overridable in specs. Default: echo to stderr.



237
238
239
# File 'lib/pangea/cli/tofu_events.rb', line 237

def yield_raw(line)
  $stderr.puts line unless line.strip.empty?
end