wide_events 0.1.3 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,13 +17,26 @@ module WideEvent
17
17
  end
18
18
 
19
19
  attr_accessor :enabled, :max_cache_attrs, :span_scopes, :strict,
20
- :logger, :error_handler, :scheduled_job_detector
20
+ :logger, :error_handler, :scheduled_job_detector,
21
+ :store_url, :store_service, :store_environment,
22
+ :store_ingest_token, :store_query_token
21
23
  attr_reader :sink, :registry_path
22
24
 
25
+ # A resolved store sink is unusable until its configuration
26
+ # (url/service/environment/ingest token) is valid; falls back to this
27
+ # silent no-op so a misconfigured store never raises into a flush.
28
+ NOOP_STORE_SINK = Object.new.tap { |sink| sink.define_singleton_method(:flush) { |_attrs| nil } }.freeze
29
+
23
30
  def initialize
24
- @enabled = !ENV["OTEL_EXPORTER_OTLP_ENDPOINT"].to_s.empty?
25
- @sink = :otel
31
+ @store_url = ENV["WIDE_EVENTS_URL"]
32
+ @enabled = @store_url.present? || ENV["OTEL_EXPORTER_OTLP_ENDPOINT"].present?
33
+ @sink = @store_url.present? ? :store : :otel
26
34
  @resolved_sink = nil
35
+ @store_service = ENV["WIDE_EVENTS_SERVICE"]
36
+ @store_environment = ENV["WIDE_EVENTS_ENVIRONMENT"]
37
+ @store_ingest_token = ENV["WIDE_EVENTS_INGEST_TOKEN"]
38
+ @store_query_token = ENV["WIDE_EVENTS_QUERY_TOKEN"]
39
+ @store_sender = nil
27
40
  @max_cache_attrs = 10
28
41
  @span_scopes = {
29
42
  "OpenTelemetry::Instrumentation::PG" => "postgres_query",
@@ -46,10 +59,18 @@ module WideEvent
46
59
  @resolved_sink ||= case @sink
47
60
  when :otel then Sinks::OtelSpan.new
48
61
  when :log then Sinks::LogLine.new(logger)
62
+ when :store then build_store_sink
49
63
  else @sink
50
64
  end
51
65
  end
52
66
 
67
+ # True once #resolved_sink has actually built (or attempted to build) a
68
+ # sink; lets WideEvent.shutdown! avoid constructing one from scratch
69
+ # just to shut it down.
70
+ def resolved?
71
+ !@resolved_sink.nil?
72
+ end
73
+
53
74
  def registry_path=(path)
54
75
  @registry_path = path
55
76
  @registry = nil
@@ -57,7 +78,56 @@ module WideEvent
57
78
 
58
79
  def registry
59
80
  return nil if @registry_path.nil?
60
- @registry ||= Registry.load(@registry_path)
81
+ @registry ||= Registry.load(resolved_registry_path)
82
+ end
83
+
84
+ # Stops the store sender's worker thread (if one was ever built) so
85
+ # tests and process shutdown never leak the background thread.
86
+ def shutdown_store_sender!(timeout: WideEvent::Store::Sender::SHUTDOWN_TIMEOUT)
87
+ @store_sender&.shutdown(timeout: timeout)
88
+ @store_sender = nil
89
+ end
90
+
91
+ private
92
+
93
+ def build_store_sink
94
+ # A prior resolution (e.g. `config.sink = :store` twice, or
95
+ # re-assigning store_* fields and re-resolving) may have already
96
+ # built and started a sender; shut it down before replacing it so
97
+ # its worker thread and queue don't strand themselves, ownerless and
98
+ # retrying forever.
99
+ shutdown_store_sender!
100
+
101
+ raise ArgumentError, "WIDE_EVENTS_SERVICE is required for the store sink" if @store_service.to_s.strip.empty?
102
+ raise ArgumentError, "WIDE_EVENTS_ENVIRONMENT is required for the store sink" if @store_environment.to_s.strip.empty?
103
+ # Client itself no longer validates token presence at construction
104
+ # (a query-only Client has no ingest token to give it) — the ingest
105
+ # path validates this here, at sink-resolution time, instead.
106
+ raise ArgumentError, "WIDE_EVENTS_INGEST_TOKEN is required for the store sink" if @store_ingest_token.to_s.strip.empty?
107
+
108
+ client = WideEvent::Store::Client.new(url: @store_url, ingest_token: @store_ingest_token)
109
+ @store_sender = WideEvent::Store::Sender.new(client: client, service: @store_service, environment: @store_environment)
110
+ Sinks::Store.new(sender: @store_sender)
111
+ rescue StandardError => e
112
+ # This instance's own handler, not the module-level WideEvent.handle_error
113
+ # funnel: build_store_sink runs on whatever Configuration instance is
114
+ # resolving (in production that's always WideEvent.config, but tests
115
+ # also resolve standalone instances directly).
116
+ report_configuration_error(e)
117
+ NOOP_STORE_SINK
118
+ end
119
+
120
+ def report_configuration_error(exception)
121
+ error_handler&.call(exception, "store_sink_configuration")
122
+ rescue StandardError
123
+ nil
124
+ end
125
+
126
+ # `registry_path` stays relative so the generated doc can commit it;
127
+ # absolute paths win inside Pathname#join and pass through as-is.
128
+ def resolved_registry_path
129
+ root = Rails.root if defined?(Rails) && Rails.respond_to?(:root)
130
+ root ? root.join(@registry_path).to_s : @registry_path
61
131
  end
62
132
  end
63
133
  end
@@ -33,6 +33,7 @@ module WideEvent
33
33
  "job.queue_latency_ms" => enqueued ? ((Time.now - enqueued) * 1000).round : nil,
34
34
  "job.scheduled" => wide_event_scheduled?
35
35
  }.merge(WideEvent.uptime_attributes))
36
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
36
37
  begin
37
38
  block.call
38
39
  rescue Exception => e
@@ -40,6 +41,7 @@ module WideEvent
40
41
  "exception.message" => e.message.to_s[0, 500])
41
42
  raise
42
43
  ensure
44
+ WideEvent.set("duration_ms" => ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000.0).round(2))
43
45
  WideEvent.flush(attrs)
44
46
  end
45
47
  end
@@ -0,0 +1,499 @@
1
+ require "yaml"
2
+ require "erb"
3
+
4
+ module WideEvent
5
+ module Kamal
6
+ # Applies the wide_events Kamal accessory to a host application's
7
+ # config/deploy.yml without disturbing unrelated comments, ordering, or
8
+ # sections. Detection of structure and conflicts uses Psych.safe_load;
9
+ # every actual mutation is a targeted line insertion/replacement on the
10
+ # original text so byte-identical content survives everywhere we did not
11
+ # intentionally touch it.
12
+ #
13
+ # Anything the line-based patcher cannot safely reason about (ERB, YAML
14
+ # aliases/merge keys, tabs, duplicate root keys, non-mapping env/
15
+ # accessories/aliases, an existing non-owned wide_events accessory,
16
+ # conflicting WIDE_EVENTS_* values, or any other shape this editor was
17
+ # not built to understand) causes a refusal: the original content is
18
+ # returned completely unchanged, alongside a complete mergeable snippet
19
+ # the caller can hand to a human or an agent.
20
+ class DeployEditor
21
+ MINIMUM_VERSION = "2.10.0"
22
+ BEGIN_MARKER = "# BEGIN wide_events store"
23
+ END_MARKER = "# END wide_events store"
24
+
25
+ CLEAR_VARS = %w[WIDE_EVENTS_URL WIDE_EVENTS_SERVICE WIDE_EVENTS_ENVIRONMENT].freeze
26
+ SECRET_VARS = %w[WIDE_EVENTS_INGEST_TOKEN WIDE_EVENTS_QUERY_TOKEN].freeze
27
+
28
+ ALIAS_TELEMETRY_KEY = "telemetry"
29
+ ALIAS_TELEMETRY_LINE = 'telemetry: app exec -i --reuse "bin/rails wide_events:sql"'
30
+ ALIAS_BACKUP_KEY = "wide-events-backup"
31
+ # --reuse is load-bearing: without it Kamal's `accessory exec` starts a
32
+ # *new* container from the store image, where (a) the image entrypoint
33
+ # already is /usr/local/bin/wide-events, so the appended command becomes
34
+ # argv[0] and main exits 2 with usage, and (b) there is no running
35
+ # server holding /run/wide-events/admin.sock to take the backup through.
36
+ # With --reuse this is a `docker exec` into the live accessory container,
37
+ # which is the only place an on-demand backup can be requested.
38
+ ALIAS_BACKUP_LINE = 'wide-events-backup: accessory exec wide_events --reuse "wide-events admin backup"'
39
+
40
+ ACCESSORY_TEMPLATE_PATH = File.expand_path(
41
+ "../../generators/wide_events/store/templates/accessory.yml.erb", __dir__
42
+ )
43
+
44
+ ROOT_KEY_RE = /\A([A-Za-z_][A-Za-z0-9_.-]*):(.*)\n?\z/
45
+
46
+ Result = Struct.new(:content, :changed, :snippet, :conflicts, keyword_init: true) do
47
+ def changed?
48
+ !!changed
49
+ end
50
+ end
51
+
52
+ # Raised internally to unwind straight to a refusal; never escapes #apply.
53
+ Refusal = Class.new(StandardError)
54
+
55
+ def initialize(content, service:, host:, hostname:, environment: "production", retention_days: 30, image: nil)
56
+ @original = content.to_s
57
+ @service = service
58
+ @host = host
59
+ @hostname = hostname
60
+ @environment = environment
61
+ @retention_days = retention_days
62
+ @image = image || "ghcr.io/adammiribyan/wide-events-store:#{WideEvent::VERSION}"
63
+ end
64
+
65
+ def apply
66
+ parsed = preflight!
67
+ lines = @original.lines
68
+ changed = false
69
+
70
+ changed |= apply_minimum_version(lines)
71
+
72
+ env_range = root_block_range(lines, "env")
73
+ changed |= apply_env_in_place(lines, env_range) if env_range
74
+
75
+ aliases_range = root_block_range(lines, "aliases")
76
+ changed |= apply_aliases_in_place(lines, aliases_range, parsed["aliases"] || {}) if aliases_range
77
+
78
+ accessories_range = root_block_range(lines, "accessories")
79
+ changed |= apply_accessories_in_place(lines, accessories_range) if accessories_range
80
+
81
+ changed |= append_env_section(lines) unless env_range
82
+ changed |= append_aliases_section(lines) unless aliases_range
83
+ changed |= append_accessories_section(lines) unless accessories_range
84
+
85
+ new_content = lines.join
86
+ verify_parseable!(new_content)
87
+
88
+ Result.new(content: new_content, changed: changed, snippet: nil, conflicts: [])
89
+ rescue Refusal => e
90
+ Result.new(content: @original, changed: false, snippet: build_snippet, conflicts: [ e.message ])
91
+ end
92
+
93
+ private
94
+
95
+ # ---- Preflight -----------------------------------------------------
96
+
97
+ def preflight!
98
+ raise Refusal, "config/deploy.yml contains ERB tags" if @original.include?("<%")
99
+ raise Refusal, "config/deploy.yml contains tab characters" if @original.include?("\t")
100
+
101
+ parsed = safe_parse(@original)
102
+ raise Refusal, "config/deploy.yml's root document is not a mapping" unless parsed.is_a?(Hash)
103
+ raise Refusal, "config/deploy.yml has duplicate root keys" if duplicate_root_keys?(@original)
104
+
105
+ raise_if_inline_value!(parsed, "env")
106
+ raise_if_inline_value!(parsed, "accessories")
107
+ raise_if_inline_value!(parsed, "aliases")
108
+
109
+ %w[env accessories aliases].each do |key|
110
+ value = parsed[key]
111
+ next if value.nil?
112
+
113
+ raise Refusal, "config/deploy.yml's #{key} key is not a mapping" unless value.is_a?(Hash)
114
+ end
115
+
116
+ env = parsed["env"] || {}
117
+ if env.key?("clear") && !env["clear"].is_a?(Hash)
118
+ raise Refusal, "config/deploy.yml's env.clear key is not a mapping"
119
+ end
120
+ if env.key?("secret") && !env["secret"].is_a?(Array)
121
+ raise Refusal, "config/deploy.yml's env.secret key is not a sequence"
122
+ end
123
+
124
+ clear = env["clear"] || {}
125
+ CLEAR_VARS.each do |key|
126
+ next unless clear.key?(key)
127
+
128
+ raise Refusal, "config/deploy.yml already sets #{key} to a different value" if clear[key].to_s != clear_value_for(key)
129
+ end
130
+
131
+ accessories = parsed["accessories"] || {}
132
+ if accessories.key?("wide_events") && !owned_accessory?(@original)
133
+ raise Refusal, "config/deploy.yml already defines an accessories.wide_events block wide_events did not create"
134
+ end
135
+
136
+ minimum_version = parsed["minimum_version"]
137
+ if minimum_version && !parseable_version?(minimum_version)
138
+ raise Refusal, "config/deploy.yml's minimum_version (#{minimum_version.inspect}) is not a recognizable version"
139
+ end
140
+
141
+ parsed
142
+ end
143
+
144
+ def safe_parse(content)
145
+ YAML.safe_load(content, permitted_classes: [], aliases: false)
146
+ rescue Psych::Exception, ArgumentError => e
147
+ raise Refusal, "config/deploy.yml could not be safely parsed (#{e.message})"
148
+ end
149
+
150
+ def raise_if_inline_value!(parsed, key)
151
+ return unless parsed.key?(key)
152
+
153
+ line = @original.lines.find { |l| (m = ROOT_KEY_RE.match(l)) && m[1] == key }
154
+ return unless line
155
+
156
+ remainder = ROOT_KEY_RE.match(line)[2].to_s.strip
157
+ return if remainder.empty? || remainder.start_with?("#")
158
+
159
+ raise Refusal, "config/deploy.yml's #{key} key uses an inline value this editor cannot merge into"
160
+ end
161
+
162
+ def duplicate_root_keys?(content)
163
+ keys = content.lines.filter_map { |l| (m = ROOT_KEY_RE.match(l)) && m[1] }
164
+ keys.size != keys.uniq.size
165
+ end
166
+
167
+ def parseable_version?(value)
168
+ Gem::Version.new(value.to_s)
169
+ true
170
+ rescue ArgumentError
171
+ false
172
+ end
173
+
174
+ # Only called once the parsed hash has already confirmed
175
+ # accessories.wide_events exists. If the line scanner cannot locate it
176
+ # (an indentation shape it does not understand, for example), that is
177
+ # a scan failure, not evidence of ownership - treat it as unowned so
178
+ # preflight refuses instead of silently accepting or replacing a
179
+ # foreign accessory it never actually verified.
180
+ def owned_accessory?(content)
181
+ lines = content.lines
182
+ range = root_block_range(lines, "accessories")
183
+ return false unless range
184
+
185
+ child_range = child_block_range(lines, range, "wide_events")
186
+ return false unless child_range
187
+
188
+ start_r, = child_range
189
+ preceding = lines[0...start_r].reverse.find { |l| !l.strip.empty? }
190
+ preceding&.strip == BEGIN_MARKER
191
+ end
192
+
193
+ def clear_value_for(key)
194
+ case key
195
+ when "WIDE_EVENTS_URL" then "https://#{@hostname}"
196
+ when "WIDE_EVENTS_SERVICE" then @service.to_s
197
+ when "WIDE_EVENTS_ENVIRONMENT" then @environment.to_s
198
+ end
199
+ end
200
+
201
+ # ---- Line-range helpers ---------------------------------------------
202
+
203
+ def child_key_regex(indent)
204
+ /\A {#{indent}}([A-Za-z_][A-Za-z0-9_.-]*):(.*)\n?\z/
205
+ end
206
+
207
+ def root_block_range(lines, key)
208
+ start_idx = lines.find_index { |l| (m = ROOT_KEY_RE.match(l)) && m[1] == key }
209
+ return nil unless start_idx
210
+
211
+ rest = lines[(start_idx + 1)..] || []
212
+ offset = rest.find_index { |l| ROOT_KEY_RE.match(l) }
213
+ end_idx = offset ? start_idx + 1 + offset : lines.length
214
+ [ start_idx, end_idx ]
215
+ end
216
+
217
+ def child_block_range(lines, range, key, indent: 2)
218
+ start_r, end_r = range
219
+ regex = child_key_regex(indent)
220
+ rel_start = ((start_r + 1)...end_r).find { |i| (m = regex.match(lines[i])) && m[1] == key }
221
+ return nil unless rel_start
222
+
223
+ rel_end = ((rel_start + 1)...end_r).find { |i| regex.match(lines[i]) }
224
+ rel_end ||= end_r
225
+ [ rel_start, rel_end ]
226
+ end
227
+
228
+ def mapping_keys(lines, range, indent: 2)
229
+ regex = child_key_regex(indent)
230
+ (range[0] + 1...range[1]).filter_map { |i| (m = regex.match(lines[i])) && m[1] }
231
+ end
232
+
233
+ def sequence_items(lines, range, indent: 4)
234
+ regex = /\A {#{indent}}- (.+?)\s*\z/
235
+ (range[0] + 1...range[1]).filter_map { |i| (m = regex.match(lines[i])) && m[1] }
236
+ end
237
+
238
+ def insert_before(lines, idx, block)
239
+ lines.insert(idx, *block)
240
+ end
241
+
242
+ # A block's naive end boundary (the next real key at the same level,
243
+ # or EOF) absorbs any trailing blank lines and standalone comments that
244
+ # actually describe whatever comes *after* this block. Trims those off
245
+ # so insertions land immediately after the block's real content
246
+ # instead of after comments that belong to the next section.
247
+ def content_end(lines, range)
248
+ start_r, end_r = range
249
+ idx = end_r
250
+ while idx > start_r + 1
251
+ previous = lines[idx - 1].strip
252
+ break unless previous.empty? || previous.start_with?("#")
253
+
254
+ idx -= 1
255
+ end
256
+ idx
257
+ end
258
+
259
+ def append_root_block(lines, block)
260
+ if lines.any? && !lines.last.end_with?("\n")
261
+ lines[-1] = "#{lines.last}\n"
262
+ end
263
+ lines << "\n" unless lines.empty? || lines.last == "\n"
264
+ lines.concat(block)
265
+ end
266
+
267
+ # ---- env: clear/secret merge ---------------------------------------
268
+
269
+ def apply_env_in_place(lines, _range)
270
+ range = root_block_range(lines, "env")
271
+ changed = ensure_env_clear(lines, range)
272
+ range = root_block_range(lines, "env")
273
+ changed |= ensure_env_secret(lines, range)
274
+ changed
275
+ end
276
+
277
+ def ensure_env_clear(lines, env_range)
278
+ clear_range = child_block_range(lines, env_range, "clear")
279
+ if clear_range.nil?
280
+ block = [
281
+ " #{BEGIN_MARKER}\n",
282
+ " clear:\n",
283
+ *CLEAR_VARS.map { |k| " #{k}: #{clear_value_for(k)}\n" },
284
+ " #{END_MARKER}\n"
285
+ ]
286
+ insert_before(lines, content_end(lines, env_range), block)
287
+ return true
288
+ end
289
+
290
+ existing = mapping_keys(lines, clear_range, indent: 4)
291
+ missing = CLEAR_VARS.reject { |k| existing.include?(k) }
292
+ return false if missing.empty?
293
+
294
+ block = [ " #{BEGIN_MARKER}\n", *missing.map { |k| " #{k}: #{clear_value_for(k)}\n" }, " #{END_MARKER}\n" ]
295
+ insert_before(lines, content_end(lines, clear_range), block)
296
+ true
297
+ end
298
+
299
+ def ensure_env_secret(lines, env_range)
300
+ secret_range = child_block_range(lines, env_range, "secret")
301
+ if secret_range.nil?
302
+ block = [
303
+ " #{BEGIN_MARKER}\n",
304
+ " secret:\n",
305
+ *SECRET_VARS.map { |k| " - #{k}\n" },
306
+ " #{END_MARKER}\n"
307
+ ]
308
+ insert_before(lines, content_end(lines, env_range), block)
309
+ return true
310
+ end
311
+
312
+ existing = sequence_items(lines, secret_range, indent: 4)
313
+ missing = SECRET_VARS.reject { |k| existing.include?(k) }
314
+ return false if missing.empty?
315
+
316
+ block = [ " #{BEGIN_MARKER}\n", *missing.map { |k| " - #{k}\n" }, " #{END_MARKER}\n" ]
317
+ insert_before(lines, content_end(lines, secret_range), block)
318
+ true
319
+ end
320
+
321
+ def append_env_section(lines)
322
+ block = [
323
+ "#{BEGIN_MARKER}\n",
324
+ "env:\n",
325
+ " clear:\n",
326
+ *CLEAR_VARS.map { |k| " #{k}: #{clear_value_for(k)}\n" },
327
+ " secret:\n",
328
+ *SECRET_VARS.map { |k| " - #{k}\n" },
329
+ "#{END_MARKER}\n"
330
+ ]
331
+ append_root_block(lines, block)
332
+ true
333
+ end
334
+
335
+ # ---- aliases merge ---------------------------------------------------
336
+
337
+ # Existence is decided from the safely-parsed hash, not a line scan:
338
+ # a quoted or otherwise unusually-written key (e.g. "telemetry": ...)
339
+ # still normalizes to the same string key in `aliases`, so relying on
340
+ # the parsed hash here (rather than the child_key_regex line scan)
341
+ # keeps an existing user alias from being silently shadowed by a
342
+ # second, identically-named key that Psych would resolve to ours.
343
+ def apply_aliases_in_place(lines, range, aliases)
344
+ missing_lines = []
345
+ missing_lines << ALIAS_TELEMETRY_LINE unless aliases.key?(ALIAS_TELEMETRY_KEY)
346
+ missing_lines << ALIAS_BACKUP_LINE unless aliases.key?(ALIAS_BACKUP_KEY)
347
+ return false if missing_lines.empty?
348
+
349
+ block = [ " #{BEGIN_MARKER}\n", *missing_lines.map { |l| " #{l}\n" }, " #{END_MARKER}\n" ]
350
+ insert_before(lines, content_end(lines, range), block)
351
+ true
352
+ end
353
+
354
+ def append_aliases_section(lines)
355
+ block = [
356
+ "#{BEGIN_MARKER}\n",
357
+ "aliases:\n",
358
+ " #{ALIAS_TELEMETRY_LINE}\n",
359
+ " #{ALIAS_BACKUP_LINE}\n",
360
+ "#{END_MARKER}\n"
361
+ ]
362
+ append_root_block(lines, block)
363
+ true
364
+ end
365
+
366
+ # ---- accessories.wide_events insertion -------------------------------
367
+
368
+ # By the time this runs, preflight has already refused any
369
+ # accessories.wide_events block this editor did not create (see
370
+ # #owned_accessory?), so a `child_range` found here is guaranteed to be
371
+ # our own marker-wrapped block. Re-rendering and replacing that whole
372
+ # marked region on every apply - rather than leaving it alone once
373
+ # created - is what makes the BEGIN/END markers useful for
374
+ # "deterministic updates": a gem upgrade (new pinned image) or a
375
+ # corrected --host/--hostname/--retention-days on a later
376
+ # `bin/rails generate wide_events:store` run actually takes effect.
377
+ # Identical re-renders leave the file byte-for-byte unchanged.
378
+ def apply_accessories_in_place(lines, range)
379
+ child_range = child_block_range(lines, range, "wide_events")
380
+ return replace_owned_accessory_block(lines, child_range) if child_range
381
+
382
+ block = [ " #{BEGIN_MARKER}\n", *indented(wide_events_template_lines, 2), " #{END_MARKER}\n" ]
383
+ insert_before(lines, content_end(lines, range), block)
384
+ true
385
+ end
386
+
387
+ def replace_owned_accessory_block(lines, child_range)
388
+ start_r, naive_end_r = child_range
389
+ start_r -= 1 if lines[start_r - 1]&.strip == BEGIN_MARKER
390
+
391
+ # child_range's naive end is the next sibling key, or (when
392
+ # wide_events is the last child) the parent's own end - which can
393
+ # extend past our own END marker into blank/comment lines that
394
+ # belong to whatever root section comes next. Search for our own
395
+ # marker explicitly instead of trusting that boundary, so the
396
+ # replacement span never reaches past it.
397
+ marker_idx = ((start_r + 1)...naive_end_r).find { |i| lines[i].strip == END_MARKER }
398
+ end_r = marker_idx ? marker_idx + 1 : naive_end_r
399
+
400
+ new_block = [ " #{BEGIN_MARKER}\n", *indented(wide_events_template_lines, 2), " #{END_MARKER}\n" ]
401
+ return false if lines[start_r...end_r] == new_block
402
+
403
+ lines[start_r...end_r] = new_block
404
+ true
405
+ end
406
+
407
+ def append_accessories_section(lines)
408
+ # Wraps only the wide_events child with markers - not the whole
409
+ # "accessories:" root key - so ownership detection (which looks for
410
+ # a marker immediately before the wide_events: line) behaves
411
+ # identically whether accessories already existed or was created
412
+ # here for the first time. That consistency is what lets a later
413
+ # re-apply find and replace this exact block instead of refusing it
414
+ # as foreign.
415
+ block = [
416
+ "accessories:\n",
417
+ " #{BEGIN_MARKER}\n",
418
+ *indented(wide_events_template_lines, 2),
419
+ " #{END_MARKER}\n"
420
+ ]
421
+ append_root_block(lines, block)
422
+ true
423
+ end
424
+
425
+ def wide_events_template_lines
426
+ template = File.read(ACCESSORY_TEMPLATE_PATH)
427
+ rendered = ERB.new(template, trim_mode: "-").result_with_hash(
428
+ image: @image,
429
+ host: @host,
430
+ hostname: @hostname,
431
+ service: @service,
432
+ environment: @environment,
433
+ retention_days: @retention_days,
434
+ local_directory: "/var/lib/#{@service}-wide-events"
435
+ )
436
+ rendered.lines
437
+ end
438
+
439
+ def indented(lines_arr, spaces)
440
+ prefix = " " * spaces
441
+ lines_arr.map { |l| l.strip.empty? ? l : "#{prefix}#{l}" }
442
+ end
443
+
444
+ # ---- Snippet for refusal ---------------------------------------------
445
+
446
+ def build_snippet
447
+ lines = []
448
+ lines << "minimum_version: #{MINIMUM_VERSION}"
449
+ lines << ""
450
+ lines << "env:"
451
+ lines << " clear:"
452
+ CLEAR_VARS.each { |k| lines << " #{k}: #{clear_value_for(k)}" }
453
+ lines << " secret:"
454
+ SECRET_VARS.each { |k| lines << " - #{k}" }
455
+ lines << ""
456
+ lines << "accessories:"
457
+ lines.concat(indented(wide_events_template_lines, 2).map(&:chomp))
458
+ lines << ""
459
+ lines << "aliases:"
460
+ lines << " #{ALIAS_TELEMETRY_LINE}"
461
+ lines << " #{ALIAS_BACKUP_LINE}"
462
+ "#{lines.join("\n")}\n"
463
+ end
464
+
465
+ # ---- Post-mutation safety net ----------------------------------------
466
+
467
+ # The line-based patcher assumes indentation/style shapes the fixtures
468
+ # cover; a shape it does not understand (mismatched sequence indents, a
469
+ # flow-style child value, unusual nesting, ...) can produce invalid
470
+ # YAML even though preflight raised no Refusal. Catching that here,
471
+ # after mutation but before returning, converts every such case into
472
+ # the same "refuse, leave the file untouched, print a snippet" contract
473
+ # as an explicit preflight refusal, instead of letting a RuntimeError
474
+ # escape #apply with a half-built Result and no snippet.
475
+ def verify_parseable!(content)
476
+ YAML.safe_load(content, permitted_classes: [], aliases: false)
477
+ rescue Psych::Exception => e
478
+ raise Refusal, "editing config/deploy.yml would produce invalid YAML (#{e.message}); this shape is not supported"
479
+ end
480
+
481
+ def apply_minimum_version(lines)
482
+ idx = lines.find_index { |l| (m = ROOT_KEY_RE.match(l)) && m[1] == "minimum_version" }
483
+ if idx
484
+ m = ROOT_KEY_RE.match(lines[idx])
485
+ existing = m[2].to_s.split("#").first.to_s.strip.gsub(/\A["']|["']\z/, "")
486
+ if existing.empty? || Gem::Version.new(existing) < Gem::Version.new(MINIMUM_VERSION)
487
+ lines[idx] = "minimum_version: #{MINIMUM_VERSION}\n"
488
+ true
489
+ else
490
+ false
491
+ end
492
+ else
493
+ lines.unshift("#{BEGIN_MARKER}\n", "minimum_version: #{MINIMUM_VERSION}\n", "#{END_MARKER}\n", "\n")
494
+ true
495
+ end
496
+ end
497
+ end
498
+ end
499
+ end