sneakers-queue-migrator 0.1.3 → 0.1.5

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f911a0cec356d8603bbe2b50353493d9699ff706d48791f823bdd49361386b88
4
- data.tar.gz: 559e63d51d1619eb0f2b1b729facef58eafc66727a650f378fd321d9d57f2b17
3
+ metadata.gz: 07b21432931960e830cbb956ab0e53406657147d9674f495ef74237f114e6fc3
4
+ data.tar.gz: aa60dd0d5714f56410240567c0a63da1fa937eefd6d3ad706bdb8a6b35cd2bdc
5
5
  SHA512:
6
- metadata.gz: db62556ef5ab33c39279ba327d4b8ce8638648a574d0716e7dcb20a71d13450940e4345796ee62421c55572c9938ad1bc5970368f76cba57d764016a7d1f4d34
7
- data.tar.gz: 6cbced20f4da4e6b45f7c4cb9d3b302aabec96bbd15cc9d9b405927e719bde2803aeaf650a576118617e980e6818bdbb7ebe0941e882d397f136c5eaf98ece8d
6
+ metadata.gz: af7f10c370532ceb2f76d908bd86fc5d5743cb1c6b491de59ef2eb3fd6749b19ccd6da1a44368c398f51281ce6440e81abd3aa6fb5d3a2f7110de84d5507b99a
7
+ data.tar.gz: 772f61353f22feea63646cebe38eaf2c6f0c2864a0041b74f54a6eba88cbf01a2c569780b3ac379899c61b6d8137253187e1376f02bfeeac53bf0a707f9241af
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- sneakers-queue-migrator (0.1.3)
4
+ sneakers-queue-migrator (0.1.5)
5
5
  bunny (~> 2.0)
6
6
  json (~> 2.0)
7
7
 
data/README.md CHANGED
@@ -35,10 +35,16 @@ require 'sneakers/migrator'
35
35
  Sneakers::Migrator.migrate!(
36
36
  amqp_url: 'amqp://user:password@rabbitmq:5672/',
37
37
  amqp_api_url: 'http://user:password@rabbitmq:15672',
38
- subscriber_paths: ['app/workers']
38
+ subscriber_paths: ['app/workers'],
39
+ load_with_regex: false
39
40
  )
40
41
  ```
41
42
 
43
+ `amqp_url` - regular bunny connection string
44
+ `amqp_api_url` - url WITH PORT for the management interface
45
+ `subscriber_paths` - array of folders to scan for subscribers
46
+ `load_with_regex` - `false` requires the files and reflects the arguments, `true` parses the from_queue block with regex
47
+
42
48
  ## Development
43
49
 
44
50
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests.
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Sneakers
4
4
  module Migrator
5
- VERSION = "0.1.3"
5
+ VERSION = "0.1.5"
6
6
  end
7
7
  end
@@ -13,19 +13,38 @@ module Sneakers
13
13
 
14
14
  class << self
15
15
  # Main migration entrypoint
16
- def migrate!(amqp_url:, amqp_api_url:, subscriber_paths: [], logger: nil)
16
+ def migrate!(amqp_url:, amqp_api_url:, subscriber_paths: [], logger: nil, load_with_regex: false)
17
17
  logger ||= $stdout
18
- # Try to require sneakers or kicks
19
- begin
20
- require "sneakers"
21
- rescue LoadError
22
- begin
23
- require "kicks"
24
- rescue LoadError
25
- raise Error, "You must have either the 'sneakers' or 'kicks' gem installed to use subscriber discovery."
18
+ # Optionally parse subscribers with regex/static analysis
19
+ queues = if load_with_regex
20
+ parse_queues_from_files(subscriber_paths, logger)
21
+ else
22
+ load_files_to_find_queues(subscriber_paths, logger)
23
+ end
24
+
25
+ conn = Bunny.new(amqp_url)
26
+ conn.start
27
+ ch = conn.create_channel
28
+
29
+ # Remove unused api_uri and fix variable usage
30
+ rabbitmq_api_user = URI.parse(amqp_api_url).user
31
+ rabbitmq_api_pass = URI.parse(amqp_api_url).password
32
+ all_queues = fetch_all_queues(amqp_api_url, rabbitmq_api_user, rabbitmq_api_pass)
33
+
34
+ queues.each do |q|
35
+ existing_queue_info = all_queues.find { |info| info["name"] == q[:name] }
36
+ if existing_queue_info.nil?
37
+ logger.puts "Migrator: Queue #{q[:name]} does not exist, creating..."
38
+ ch.queue(q[:name], durable: q[:durable], arguments: q[:arguments])
39
+ next
26
40
  end
41
+ migrate_queue!(q, ch, amqp_api_url, rabbitmq_api_user, rabbitmq_api_pass, logger, existing_queue_info)
27
42
  end
43
+ end
28
44
 
45
+ private
46
+
47
+ def load_files_to_find_queues(subscriber_paths, logger)
29
48
  # Load all subscribers (recursively)
30
49
  subscriber_paths.each do |path|
31
50
  Dir[File.join(path, "**", "*.rb")].sort.each do |f|
@@ -51,10 +70,10 @@ module Sneakers
51
70
  logger.puts "Migrator: Found #{subscribers.size} subscribers:"
52
71
  subscribers.each { |s| logger.puts " - #{s.name}" }
53
72
 
54
- queues = subscribers.map do |klass|
73
+ subscribers.map do |klass|
55
74
  opts = klass.instance_variable_get(:@queue_opts) || {}
56
- name = klass.instance_variable_get(:@queue_name) ||
57
- (klass.const_defined?(:QUEUE_NAME) ? klass.const_get(:QUEUE_NAME) : nil) ||
75
+ name = klass.instance_variable_get(:@queue_name) || \
76
+ (klass.const_defined?(:QUEUE_NAME) ? klass.const_get(:QUEUE_NAME) : nil) || \
58
77
  klass.name.gsub("::", "_").gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
59
78
  {
60
79
  name: name,
@@ -64,28 +83,205 @@ module Sneakers
64
83
  durable: opts.fetch(:durable, true)
65
84
  }.tap { |q| q[:arguments]["x-queue-type"] ||= "classic" }
66
85
  end.uniq { |q| q[:name] }
86
+ end
67
87
 
68
- conn = Bunny.new(amqp_url)
69
- conn.start
70
- ch = conn.create_channel
88
+ # Robustly parse all from_queue calls in .rb files under subscriber_paths
89
+ def parse_queues_from_files(subscriber_paths, logger)
90
+ require "ripper"
91
+ require "yaml"
92
+ queues = []
93
+ seen = {}
94
+ subscriber_paths.each do |path|
95
+ Dir[File.join(path, "**", "*.rb")].sort.each do |file|
96
+ logger.puts "Migrator: Parsing subscriber file: #{file}"
97
+ src = File.read(file)
98
+ # Use a robust regex to find from_queue calls (multi-line, with hash)
99
+ # Improved: allow multi-line, nested, and indented argument hashes
100
+ # Custom scan to robustly extract the full options hash or argument list (handles nested braces)
101
+ # Use a more robust approach: for each from_queue, extract the full argument list (multi-line, nested)
102
+ # Multi-line robust extraction for from_queue calls
103
+ src_lines = src.lines
104
+ src_lines.each_with_index do |line, idx|
105
+ # Match from_queue with or without parentheses, any quote style, and allow whitespace
106
+ next unless line =~ /from_queue\s*(\(|\s)+(['"])([^'"]+)\2\s*,?/
71
107
 
72
- # Remove unused api_uri and fix variable usage
73
- rabbitmq_api_user = URI.parse(amqp_api_url).user
74
- rabbitmq_api_pass = URI.parse(amqp_api_url).password
75
- all_queues = fetch_all_queues(amqp_api_url, rabbitmq_api_user, rabbitmq_api_pass)
108
+ queue_name = ::Regexp.last_match(3)
109
+ # Multi-line argument collection: collect all subsequent lines that are more indented than the from_queue line
110
+ from_indent = line[/^\s*/].size
111
+ opts_lines = []
112
+ j = idx + 1
113
+ while j < src_lines.size
114
+ l = src_lines[j]
115
+ # Stop if line is less indented or blank or looks like a new method/class/def
116
+ break if l.strip.empty? || l[/^\s*/].size <= from_indent || l =~ /^\s*(def |class |module )/
76
117
 
77
- queues.each do |q|
78
- existing_queue_info = all_queues.find { |info| info["name"] == q[:name] }
79
- if existing_queue_info.nil?
80
- logger.puts "Migrator: Queue #{q[:name]} does not exist, creating..."
81
- ch.queue(q[:name], durable: q[:durable], arguments: q[:arguments])
82
- next
118
+ opts_lines << l
119
+ j += 1
120
+ end
121
+ opts_str = opts_lines.join(" ").strip
122
+ puts "Migrator: Parsing queue: #{queue_name} with options: #{opts_str}"
123
+ opts_hash = parse_options_hash_for_file(opts_str)
124
+ puts "Migrator: Parsed options for #{queue_name}: #{opts_hash.inspect}"
125
+ routing_keys = opts_hash[:routing_key]
126
+ routing_keys = [routing_keys] if routing_keys && !routing_keys.is_a?(Array)
127
+ arguments = opts_hash[:arguments] || {}
128
+ # Always stringify keys deeply for arguments
129
+ arguments = stringify_keys_deep(arguments)
130
+ # Add "x-queue-type" if not present as string
131
+ arguments["x-queue-type"] ||= "classic"
132
+ # Remove any symbol keys that duplicate string keys
133
+ arguments.delete_if { |k, _| k.is_a?(Symbol) && arguments.key?(k.to_s) }
134
+
135
+ exchange_options = opts_hash[:exchange_options] || {}
136
+ exchange_options = stringify_keys_deep(exchange_options)
137
+ exchange_options.delete_if { |k, _| k.is_a?(Symbol) && exchange_options.key?(k.to_s) }
138
+
139
+ q = {
140
+ name: queue_name,
141
+ exchange: opts_hash[:exchange],
142
+ exchange_options: exchange_options,
143
+ routing_keys: routing_keys || [],
144
+ arguments: arguments,
145
+ durable: opts_hash.key?(:durable) ? opts_hash[:durable] : true
146
+ }
147
+ unless seen[q[:name]]
148
+ queues << q
149
+ seen[q[:name]] = true
150
+ end
151
+ queues
152
+ end
83
153
  end
84
- migrate_queue!(q, ch, amqp_api_url, rabbitmq_api_user, rabbitmq_api_pass, logger, existing_queue_info)
85
154
  end
155
+ puts "Migrator: Found #{queues.size} queues to migrate:"
156
+ queues.each { |q| puts " - #{q[:name]} (exchange: #{q[:exchange]}, routing_keys: #{Array(q[:routing_keys]).join(", ")}, arguments: #{q[:arguments].inspect})" }
157
+ queues
86
158
  end
87
159
 
88
- private
160
+ # Recursively convert all hash keys to strings
161
+ def stringify_keys_deep(obj)
162
+ case obj
163
+ when Hash
164
+ obj.each_with_object({}) { |(k, v), h| h[k.to_s] = stringify_keys_deep(v) }
165
+ when Array
166
+ obj.map { |v| stringify_keys_deep(v) }
167
+ else
168
+ obj
169
+ end
170
+ end
171
+
172
+ # Parse a Ruby hash from a string for file-based queue declarations (robust for multi-line, symbol, string keys, nested hashes)
173
+ def parse_options_hash_for_file(str)
174
+ return {} unless str
175
+
176
+ # Remove comments and excessive whitespace
177
+ code = str.gsub(/#.*$/, "").gsub(/\n/, " ").gsub(/\s+/, " ")
178
+ # Convert Ruby %w[ ... ] and %w( ... ) to array syntax for YAML
179
+ code = code.gsub(/%w[\[(](.*?)[\])]/) do
180
+ words = ::Regexp.last_match(1).strip.split(/\s+/)
181
+ "[" + words.map { |w| "\"#{w}\"" }.join(", ") + "]" # rubocop:disable Style/StringConcatenation
182
+ end
183
+ # Convert Ruby hashrockets and symbol keys to YAML style, and handle symbol values and numbers with underscores
184
+ yaml_str = code
185
+ .gsub(/:(\w+)\s*=>/, '"\\1":') # :foo =>
186
+ .gsub(/(\w+):/, '"\\1":') # foo:
187
+ .gsub(/=>/, ":") # fallback for any =>
188
+ .gsub(/:([\w\d_]+)/, '"\\1"') # symbol values :foo => "foo"
189
+ .gsub(/([\d_]+)(?=,|\s|\})/) { |m| m.delete("_") } # numbers with underscores
190
+ # Ensure the string is wrapped in braces for YAML parsing
191
+ yaml_str = "{#{yaml_str}}" unless yaml_str.strip.start_with?("{") && yaml_str.strip.end_with?("}")
192
+ begin
193
+ # Try to parse as YAML (single argument for compatibility)
194
+ hash = YAML.safe_load(yaml_str) || {}
195
+ # Symbolize keys recursively and dedupe symbol/string keys
196
+ dedupe_symbol_and_string_keys_deep(symbolize_keys_deep(hash))
197
+ rescue StandardError => e
198
+ warn "Migrator: parse_options_hash_for_file YAML parse error: #{e.class}: #{e.message}"
199
+ # Fallback: try to extract common keys manually
200
+ out = {}
201
+ out[:exchange] = ::Regexp.last_match(1) if str =~ /(?:["']?exchange["']?\s*(?:=>|:)\s*["']?([^"'\s,}]+)["']?)/
202
+ out[:routing_key] = ::Regexp.last_match(1) if str =~ /(?:["']?routing_key["']?\s*(?:=>|:)\s*["']?([\w\-:]+)["']?)/
203
+ if str =~ /arguments:\s*\{([^}]*)\}/m
204
+ begin
205
+ args = YAML.safe_load("{#{::Regexp.last_match(1)}}") || {}
206
+ out[:arguments] = dedupe_symbol_and_string_keys_deep(symbolize_keys_deep(args))
207
+ rescue StandardError => e2
208
+ warn "Migrator: parse_options_hash_for_file arguments parse error: #{e2.class}: #{e2.message}"
209
+ end
210
+ end
211
+ if str =~ /exchange_options:\s*\{([^}]*)\}/m
212
+ begin
213
+ ex_opts = YAML.safe_load("{#{::Regexp.last_match(1)}}") || {}
214
+ out[:exchange_options] = dedupe_symbol_and_string_keys_deep(symbolize_keys_deep(ex_opts))
215
+ rescue StandardError => e3
216
+ warn "Migrator: parse_options_hash_for_file exchange_options parse error: #{e3.class}: #{e3.message}"
217
+ end
218
+ end
219
+ out
220
+ end
221
+ end
222
+
223
+ # Remove string keys if the same key exists as a symbol, recursively
224
+ def dedupe_symbol_and_string_keys_deep(obj)
225
+ case obj
226
+ when Hash
227
+ # Remove string keys if symbol key exists
228
+ symbol_keys = obj.keys.select { |k| k.is_a?(Symbol) }
229
+ obj.reject { |k, _| k.is_a?(String) && symbol_keys.include?(k.to_sym) }
230
+ .transform_values { |v| dedupe_symbol_and_string_keys_deep(v) }
231
+ when Array
232
+ obj.map { |v| dedupe_symbol_and_string_keys_deep(v) }
233
+ else
234
+ obj
235
+ end
236
+ end
237
+
238
+ # Recursively symbolize keys in a hash
239
+ def symbolize_keys_deep(obj)
240
+ case obj
241
+ when Hash
242
+ obj.each_with_object({}) { |(k, v), h| h[k.to_sym] = symbolize_keys_deep(v) }
243
+ when Array
244
+ obj.map { |v| symbolize_keys_deep(v) }
245
+ else
246
+ obj
247
+ end
248
+ end
249
+
250
+ # Parse a Ruby hash from a string (robust for multi-line, symbol, and string keys)
251
+ # This is used for parsing options from rabbitmq management API strings
252
+ def parse_options_hash(str)
253
+ return {} unless str
254
+
255
+ # Remove newlines and excessive whitespace for easier parsing
256
+ compact = str.gsub(/\n/, " ").gsub(/\s+/, " ")
257
+ # Convert Ruby symbols and hashrockets to YAML style
258
+ yaml_str = compact
259
+ .gsub(/:(\w+)\s*=>/, '"\\1":') # :foo =>
260
+ .gsub(/(\w+):/, '"\\1":') # foo:
261
+ .gsub(/=>/, ":") # fallback for any =>
262
+ begin
263
+ # Try to parse as YAML (single argument for compatibility)
264
+ YAML.safe_load("{#{yaml_str}}") || {}
265
+ rescue StandardError => e
266
+ warn "Migrator: parse_options_hash YAML parse error: #{e.class}: #{e.message}"
267
+ # Fallback: try to extract exchange and routing_key robustly (symbol or string keys, colon or hashrocket, any quotes)
268
+ out = {}
269
+ # Match exchange: "exchange" => "test", :exchange => 'test', exchange: "test", etc.
270
+ out[:exchange] = ::Regexp.last_match(1) if str =~ /(?:["']?exchange["']?\s*(?:=>|:)\s*["']?([^"'\s,}]+)["']?)/
271
+ # Match routing_key: "routing_key" => "foo", :routing_key => 'foo', routing_key: "foo", etc.
272
+ out[:routing_key] = ::Regexp.last_match(1) if str =~ /(?:["']?routing_key["']?\s*(?:=>|:)\s*["']?([\w\-:]+)["']?)/
273
+ if str =~ /arguments:\s*\{([^}]*)\}/m
274
+ # Try to parse arguments as a hash
275
+ begin
276
+ args = YAML.safe_load("{#{::Regexp.last_match(1)}}") || {}
277
+ out[:arguments] = args
278
+ rescue StandardError => e2
279
+ warn "Migrator: parse_options_hash arguments parse error: #{e2.class}: #{e2.message}"
280
+ end
281
+ end
282
+ out
283
+ end
284
+ end
89
285
 
90
286
  def migrate_queue!(q, ch, amqp_api_url, rabbitmq_api_user, rabbitmq_api_pass, logger, existing_queue_info)
91
287
  tmp_queue_name = "#{q[:name]}_tmp"
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sneakers-queue-migrator
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.1.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mike Gane
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2025-07-03 00:00:00.000000000 Z
10
+ date: 2025-07-04 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: bunny