sneakers-queue-migrator 0.1.2 → 0.1.4

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: 3531ca5afff55adbc8e8da879f3d82d8aa7018197a3da1fcde32cc5adfa117c7
4
- data.tar.gz: a437f2c1146e3f4945351d4bf713db726085ba92f34e614f0a93f04aa21dca64
3
+ metadata.gz: aa500b657d410fd1686b993cdeea8c99b415b8cf502c66b4f29cca6aa9104554
4
+ data.tar.gz: b46319b9ff9a6b1821aaf63b5895e98df85e1be4b980f8f25a320ceacdb9a003
5
5
  SHA512:
6
- metadata.gz: 97a3d839285593a04c2f5fec1a45694f1fe740720dfb03bb3185c89558d196e398ea237e13d317d86a77ac7f1d240176aeccfbd4b9212dd3d1c7ff060e3b54c0
7
- data.tar.gz: d1e9f3c98b5464cea668abff64c1f281f3132077288371b83550d816973c7ae7007a09f44ff4842d19374c53e8f426edbf8a72b63edc1191b3d623e63b6aa267
6
+ metadata.gz: 600d85156bacd65cd41005f58ee3d41f0343ca3aef7fd8c66556da258d0b8112b9fb6dc41c132b5abc41b3bf2369c1b3ac4db6185660c670e7463c2ca68768fd
7
+ data.tar.gz: 4a3644e771e0f88f3ee15167e2db7c10ea486b31c84e30a963adf6d122754499fb61f11ffe5db4d29fa3b41fa330aca9b1d57f354959ebd378ee21c465bf4e39
data/.rubocop.yml CHANGED
@@ -35,5 +35,11 @@ Metrics/MethodLength:
35
35
  Metrics/PerceivedComplexity:
36
36
  Enabled: false
37
37
 
38
+ Metrics/ParameterLists:
39
+ Enabled: false
40
+
38
41
  Layout/LineLength:
39
42
  Enabled: false
43
+
44
+ Naming/MethodParameterName:
45
+ Enabled: false
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- sneakers-queue-migrator (0.1.2)
4
+ sneakers-queue-migrator (0.1.4)
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
+ parse_only: 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
+ `parse_only` - `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.2"
5
+ VERSION = "0.1.4"
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, parse_only: 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 parse_only
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
44
+
45
+ private
28
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|
@@ -36,6 +55,7 @@ module Sneakers
36
55
  logger.puts "Migrator: Failed to load subscriber file #{f}: #{e.message}"
37
56
  rescue StandardError => e
38
57
  logger.puts "Migrator: Error loading subscriber file #{f}: #{e.class}: #{e.message}"
58
+ end
39
59
  end
40
60
  end
41
61
 
@@ -50,10 +70,10 @@ module Sneakers
50
70
  logger.puts "Migrator: Found #{subscribers.size} subscribers:"
51
71
  subscribers.each { |s| logger.puts " - #{s.name}" }
52
72
 
53
- queues = subscribers.map do |klass|
73
+ subscribers.map do |klass|
54
74
  opts = klass.instance_variable_get(:@queue_opts) || {}
55
- name = klass.instance_variable_get(:@queue_name) ||
56
- (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) || \
57
77
  klass.name.gsub("::", "_").gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
58
78
  {
59
79
  name: name,
@@ -63,28 +83,198 @@ module Sneakers
63
83
  durable: opts.fetch(:durable, true)
64
84
  }.tap { |q| q[:arguments]["x-queue-type"] ||= "classic" }
65
85
  end.uniq { |q| q[:name] }
86
+ end
66
87
 
67
- conn = Bunny.new(amqp_url)
68
- conn.start
69
- 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
+ next unless line =~ /from_queue\s+("|')([\w\-:]+)\1\s*,?\s*$/
70
106
 
71
- # Remove unused api_uri and fix variable usage
72
- rabbitmq_api_user = URI.parse(amqp_api_url).user
73
- rabbitmq_api_pass = URI.parse(amqp_api_url).password
74
- all_queues = fetch_all_queues(amqp_api_url, rabbitmq_api_user, rabbitmq_api_pass)
107
+ queue_name = ::Regexp.last_match(2)
108
+ # Multi-line argument collection: collect all subsequent lines that are more indented than the from_queue line
109
+ from_indent = line[/^\s*/].size
110
+ opts_lines = []
111
+ j = idx + 1
112
+ while j < src_lines.size
113
+ l = src_lines[j]
114
+ # Stop if line is less indented or blank or looks like a new method/class/def
115
+ break if l.strip.empty? || l[/^\s*/].size <= from_indent || l =~ /^\s*(def |class |module )/
75
116
 
76
- queues.each do |q|
77
- existing_queue_info = all_queues.find { |info| info["name"] == q[:name] }
78
- if existing_queue_info.nil?
79
- logger.puts "Migrator: Queue #{q[:name]} does not exist, creating..."
80
- ch.queue(q[:name], durable: q[:durable], arguments: q[:arguments])
81
- next
117
+ opts_lines << l
118
+ j += 1
119
+ end
120
+ opts_str = opts_lines.join(" ").strip
121
+ puts "Migrator: Parsing queue: #{queue_name} with options: #{opts_str}"
122
+ opts_hash = parse_options_hash_for_file(opts_str)
123
+ puts "Migrator: Parsed options for #{queue_name}: #{opts_hash.inspect}"
124
+ routing_keys = opts_hash[:routing_key]
125
+ routing_keys = [routing_keys] if routing_keys && !routing_keys.is_a?(Array)
126
+ arguments = opts_hash[:arguments] || {}
127
+ # Always stringify keys deeply for arguments
128
+ arguments = stringify_keys_deep(arguments)
129
+ # Add "x-queue-type" if not present as string
130
+ arguments["x-queue-type"] ||= "classic"
131
+ # Remove any symbol keys that duplicate string keys
132
+ arguments.delete_if { |k, _| k.is_a?(Symbol) && arguments.key?(k.to_s) }
133
+
134
+ exchange_options = opts_hash[:exchange_options] || {}
135
+ exchange_options = stringify_keys_deep(exchange_options)
136
+ exchange_options.delete_if { |k, _| k.is_a?(Symbol) && exchange_options.key?(k.to_s) }
137
+
138
+ q = {
139
+ name: queue_name,
140
+ exchange: opts_hash[:exchange],
141
+ exchange_options: exchange_options,
142
+ routing_keys: routing_keys || [],
143
+ arguments: arguments,
144
+ durable: opts_hash.key?(:durable) ? opts_hash[:durable] : true
145
+ }
146
+ unless seen[q[:name]]
147
+ queues << q
148
+ seen[q[:name]] = true
149
+ end
150
+ end
82
151
  end
83
- migrate_queue!(q, ch, amqp_api_url, rabbitmq_api_user, rabbitmq_api_pass, logger, existing_queue_info)
84
152
  end
153
+ puts "Migrator: Found #{queues.size} queues to migrate:"
154
+ queues.each { |q| puts " - #{q[:name]} (exchange: #{q[:exchange]}, routing_keys: #{Array(q[:routing_keys]).join(", ")}, arguments: #{q[:arguments].inspect})" }
155
+ queues
85
156
  end
86
157
 
87
- private
158
+ # Recursively convert all hash keys to strings
159
+ def stringify_keys_deep(obj)
160
+ case obj
161
+ when Hash
162
+ obj.each_with_object({}) { |(k, v), h| h[k.to_s] = stringify_keys_deep(v) }
163
+ when Array
164
+ obj.map { |v| stringify_keys_deep(v) }
165
+ else
166
+ obj
167
+ end
168
+ end
169
+
170
+ # Parse a Ruby hash from a string for file-based queue declarations (robust for multi-line, symbol, string keys, nested hashes)
171
+ def parse_options_hash_for_file(str)
172
+ return {} unless str
173
+
174
+ # Remove comments and excessive whitespace
175
+ code = str.gsub(/#.*$/, "").gsub(/\n/, " ").gsub(/\s+/, " ")
176
+ # Convert Ruby hashrockets and symbol keys to YAML style, and handle symbol values and numbers with underscores
177
+ yaml_str = code
178
+ .gsub(/:(\w+)\s*=>/, '"\\1":') # :foo =>
179
+ .gsub(/(\w+):/, '"\\1":') # foo:
180
+ .gsub(/=>/, ":") # fallback for any =>
181
+ .gsub(/:([\w\d_]+)/, '"\\1"') # symbol values :foo => "foo"
182
+ .gsub(/([\d_]+)(?=,|\s|\})/) { |m| m.delete("_") } # numbers with underscores
183
+ # Ensure the string is wrapped in braces for YAML parsing
184
+ yaml_str = "{#{yaml_str}}" unless yaml_str.strip.start_with?("{") && yaml_str.strip.end_with?("}")
185
+ begin
186
+ # Try to parse as YAML (single argument for compatibility)
187
+ hash = YAML.safe_load(yaml_str) || {}
188
+ # Symbolize keys recursively and dedupe symbol/string keys
189
+ dedupe_symbol_and_string_keys_deep(symbolize_keys_deep(hash))
190
+ rescue StandardError => e
191
+ warn "Migrator: parse_options_hash_for_file YAML parse error: #{e.class}: #{e.message}"
192
+ # Fallback: try to extract common keys manually
193
+ out = {}
194
+ out[:exchange] = ::Regexp.last_match(1) if str =~ /(?:["']?exchange["']?\s*(?:=>|:)\s*["']?([^"'\s,}]+)["']?)/
195
+ out[:routing_key] = ::Regexp.last_match(1) if str =~ /(?:["']?routing_key["']?\s*(?:=>|:)\s*["']?([\w\-:]+)["']?)/
196
+ if str =~ /arguments:\s*\{([^}]*)\}/m
197
+ begin
198
+ args = YAML.safe_load("{#{::Regexp.last_match(1)}}") || {}
199
+ out[:arguments] = dedupe_symbol_and_string_keys_deep(symbolize_keys_deep(args))
200
+ rescue StandardError => e2
201
+ warn "Migrator: parse_options_hash_for_file arguments parse error: #{e2.class}: #{e2.message}"
202
+ end
203
+ end
204
+ if str =~ /exchange_options:\s*\{([^}]*)\}/m
205
+ begin
206
+ ex_opts = YAML.safe_load("{#{::Regexp.last_match(1)}}") || {}
207
+ out[:exchange_options] = dedupe_symbol_and_string_keys_deep(symbolize_keys_deep(ex_opts))
208
+ rescue StandardError => e3
209
+ warn "Migrator: parse_options_hash_for_file exchange_options parse error: #{e3.class}: #{e3.message}"
210
+ end
211
+ end
212
+ out
213
+ end
214
+ end
215
+
216
+ # Remove string keys if the same key exists as a symbol, recursively
217
+ def dedupe_symbol_and_string_keys_deep(obj)
218
+ case obj
219
+ when Hash
220
+ # Remove string keys if symbol key exists
221
+ symbol_keys = obj.keys.select { |k| k.is_a?(Symbol) }
222
+ obj.reject { |k, _| k.is_a?(String) && symbol_keys.include?(k.to_sym) }
223
+ .transform_values { |v| dedupe_symbol_and_string_keys_deep(v) }
224
+ when Array
225
+ obj.map { |v| dedupe_symbol_and_string_keys_deep(v) }
226
+ else
227
+ obj
228
+ end
229
+ end
230
+
231
+ # Recursively symbolize keys in a hash
232
+ def symbolize_keys_deep(obj)
233
+ case obj
234
+ when Hash
235
+ obj.each_with_object({}) { |(k, v), h| h[k.to_sym] = symbolize_keys_deep(v) }
236
+ when Array
237
+ obj.map { |v| symbolize_keys_deep(v) }
238
+ else
239
+ obj
240
+ end
241
+ end
242
+
243
+ # Parse a Ruby hash from a string (robust for multi-line, symbol, and string keys)
244
+ # This is used for parsing options from rabbitmq management API strings
245
+ def parse_options_hash(str)
246
+ return {} unless str
247
+
248
+ # Remove newlines and excessive whitespace for easier parsing
249
+ compact = str.gsub(/\n/, " ").gsub(/\s+/, " ")
250
+ # Convert Ruby symbols and hashrockets to YAML style
251
+ yaml_str = compact
252
+ .gsub(/:(\w+)\s*=>/, '"\\1":') # :foo =>
253
+ .gsub(/(\w+):/, '"\\1":') # foo:
254
+ .gsub(/=>/, ":") # fallback for any =>
255
+ begin
256
+ # Try to parse as YAML (single argument for compatibility)
257
+ YAML.safe_load("{#{yaml_str}}") || {}
258
+ rescue StandardError => e
259
+ warn "Migrator: parse_options_hash YAML parse error: #{e.class}: #{e.message}"
260
+ # Fallback: try to extract exchange and routing_key robustly (symbol or string keys, colon or hashrocket, any quotes)
261
+ out = {}
262
+ # Match exchange: "exchange" => "test", :exchange => 'test', exchange: "test", etc.
263
+ out[:exchange] = ::Regexp.last_match(1) if str =~ /(?:["']?exchange["']?\s*(?:=>|:)\s*["']?([^"'\s,}]+)["']?)/
264
+ # Match routing_key: "routing_key" => "foo", :routing_key => 'foo', routing_key: "foo", etc.
265
+ out[:routing_key] = ::Regexp.last_match(1) if str =~ /(?:["']?routing_key["']?\s*(?:=>|:)\s*["']?([\w\-:]+)["']?)/
266
+ if str =~ /arguments:\s*\{([^}]*)\}/m
267
+ # Try to parse arguments as a hash
268
+ begin
269
+ args = YAML.safe_load("{#{::Regexp.last_match(1)}}") || {}
270
+ out[:arguments] = args
271
+ rescue StandardError => e2
272
+ warn "Migrator: parse_options_hash arguments parse error: #{e2.class}: #{e2.message}"
273
+ end
274
+ end
275
+ out
276
+ end
277
+ end
88
278
 
89
279
  def migrate_queue!(q, ch, amqp_api_url, rabbitmq_api_user, rabbitmq_api_pass, logger, existing_queue_info)
90
280
  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.2
4
+ version: 0.1.4
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