monq 0.1.0

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.
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monq/table"
4
+
5
+ module Monq
6
+ module Commands
7
+ module Models
8
+ HEADERS = ["Table Name", "Model Class", "Abbr"].freeze
9
+
10
+ module_function
11
+
12
+ def render(envs, env_regexp = nil, collection_regexp = nil, format: "terminal")
13
+ envs.filter_map { |env| render_env(env, collection_regexp, format) if match_env?(env, env_regexp) }.join("\n")
14
+ end
15
+
16
+ def render_env(env, collection_regexp, format)
17
+ Table.new("Environment: #{env.name}") do |table|
18
+ table.headers = HEADERS
19
+ Array(env.models).each do |klass|
20
+ collection = collection_name(klass)
21
+ next unless match_collection?(klass, collection, collection_regexp)
22
+
23
+ table.body << [collection, klass.name, abbreviation(env, klass)]
24
+ end
25
+ end.to_terminal(format)
26
+ end
27
+
28
+ def match_collection?(klass, collection, collection_regexp)
29
+ regexp = coerce_regexp(collection_regexp)
30
+ return true unless regexp
31
+
32
+ collection.to_s =~ regexp || klass.name.to_s =~ regexp
33
+ end
34
+
35
+ def coerce_regexp(value)
36
+ return nil if value.nil? || value.to_s.empty?
37
+ return value if value.is_a?(Regexp)
38
+
39
+ source = value.to_s
40
+ if source.start_with?("/") && source.end_with?("/") && source.length > 1
41
+ Regexp.new(source[1..-2])
42
+ else
43
+ Regexp.new(source, Regexp::IGNORECASE)
44
+ end
45
+ end
46
+
47
+ def match_env?(env, env_regexp)
48
+ return true if env_regexp.nil? || env_regexp.to_s.empty?
49
+
50
+ env.name =~ coerce_regexp(env_regexp)
51
+ end
52
+
53
+ def collection_name(klass)
54
+ options = klass.storage_options
55
+ options[:collection] || options["collection"]
56
+ rescue StandardError
57
+ nil
58
+ end
59
+
60
+ def abbreviation(env, klass)
61
+ mod = env.namespace_module
62
+ mod.constants.each do |name|
63
+ next if name.to_s == klass.name.to_s.split("::").last
64
+
65
+ return name.to_s if mod.const_get(name) == klass
66
+ end
67
+ ""
68
+ rescue StandardError
69
+ ""
70
+ end
71
+
72
+ def register_pry_command!
73
+ return if Pry.commands["m"]
74
+
75
+ Pry.commands.create_command("m", "List loaded models: m [-e env] [-f format] [collection_regexp]") do
76
+ def options(opt)
77
+ opt.on "-e", "--env", "Environment name regexp", argument: true, as: String
78
+ opt.on "-f", "--format", "Table format: terminal, md, org", argument: true, as: String, default: "terminal"
79
+ end
80
+
81
+ def process
82
+ output.puts Monq::Commands::Models.render(
83
+ Monq::App.instance.environments,
84
+ opts[:env],
85
+ args.first,
86
+ format: opts[:format] || "terminal"
87
+ )
88
+ end
89
+ end
90
+
91
+ Pry.commands.alias_command "l", "m"
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Monq
4
+ module Commands
5
+ module Reconnect
6
+ module_function
7
+
8
+ def run(envs, force: false)
9
+ raise Error, "sandbox is active; run sandbox-quit first" if envs.any?(&:sandbox?)
10
+
11
+ envs.each do |env|
12
+ reconnect_ssh(env, force: force)
13
+ if force
14
+ env.reconnect!
15
+ else
16
+ env.reconnect! unless env.ping?
17
+ end
18
+ end
19
+ end
20
+
21
+ def reconnect_ssh(env, force:)
22
+ proxy = env.ssh_proxy
23
+ return unless proxy
24
+
25
+ if force
26
+ proxy.reconnect! if proxy.respond_to?(:reconnect!)
27
+ elsif proxy.respond_to?(:reconnect)
28
+ proxy.reconnect
29
+ end
30
+ end
31
+
32
+ def register_pry_command!
33
+ return if Pry.commands["reconnect"]
34
+
35
+ Pry.commands.block_command("reconnect", "Reconnect dead clients") do
36
+ Monq::Commands::Reconnect.run(Monq::App.instance.environments, force: false)
37
+ end
38
+
39
+ Pry.commands.block_command("reconnect!", "Force reconnect clients") do
40
+ Monq::Commands::Reconnect.run(Monq::App.instance.environments, force: true)
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Monq
4
+ module Commands
5
+ module Redefine
6
+ module_function
7
+
8
+ def run(envs)
9
+ raise Error, "sandbox is active; run sandbox-quit first" if envs.any?(&:sandbox?)
10
+
11
+ envs.each(&:redefine!)
12
+ end
13
+
14
+ def register_pry_command!
15
+ return if Pry.commands["redefine"]
16
+
17
+ Pry.commands.block_command("redefine", "Reload model files") do
18
+ Monq::Commands::Redefine.run(Monq::App.instance.environments)
19
+ end
20
+
21
+ Pry.commands.alias_command "redef", "redefine"
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Monq
4
+ module Commands
5
+ module Sandbox
6
+ module_function
7
+
8
+ def enter(envs)
9
+ return warn("already in sandbox") if envs.any?(&:sandbox?)
10
+
11
+ hellos = envs.map { |env| env.client.database.command(hello: 1).documents.first }
12
+ if hellos.any? { |doc| !transaction_capable?(doc) }
13
+ raise Error, "current deployment does not support transactions (need replica set or mongos); not entering sandbox"
14
+ end
15
+
16
+ started = []
17
+ envs.each do |env|
18
+ session = env.client.start_session
19
+ session.start_transaction
20
+ env.bind_session(session)
21
+ started << [env, session]
22
+ rescue StandardError
23
+ started.reverse_each do |e, s|
24
+ s.abort_transaction
25
+ s.end_session
26
+ e.clear_session
27
+ end
28
+ raise
29
+ end
30
+ end
31
+
32
+ def quit(envs)
33
+ envs.each(&:clear_session)
34
+ end
35
+
36
+ def transaction_capable?(doc)
37
+ return false if doc.nil?
38
+
39
+ set_name = doc["setName"] || doc[:setName]
40
+ msg = doc["msg"] || doc[:msg]
41
+ !set_name.nil? || msg == "isdbgrid"
42
+ end
43
+
44
+ def register_pry_command!
45
+ return if Pry.commands["sandbox-enter"]
46
+
47
+ Pry.commands.block_command("sandbox-enter", "Start an all-or-nothing transaction on every environment") do
48
+ Monq::Commands::Sandbox.enter(Monq::App.instance.environments)
49
+ end
50
+
51
+ Pry.commands.block_command("sandbox-quit", "Abort sandbox transactions and end their sessions") do
52
+ Monq::Commands::Sandbox.quit(Monq::App.instance.environments)
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+ require "json"
5
+ require "monq/multi_io"
6
+
7
+ module Monq
8
+ module Commands
9
+ module ShowSql
10
+ module Subscriber
11
+ module_function
12
+
13
+ def started(event)
14
+ write("started", event)
15
+ end
16
+
17
+ def succeeded(event)
18
+ write("succeeded", event)
19
+ end
20
+
21
+ def failed(event)
22
+ write("failed", event)
23
+ end
24
+
25
+ def write(phase, event)
26
+ io = App.log_io
27
+ return unless io
28
+
29
+ payload = {
30
+ phase: phase,
31
+ command_name: event.respond_to?(:command_name) ? event.command_name : nil,
32
+ database: event.respond_to?(:database_name) ? event.database_name : nil,
33
+ command: jsonable(event.respond_to?(:command) ? event.command : nil),
34
+ duration: event.respond_to?(:duration) ? event.duration : nil
35
+ }
36
+ io.write("#{JSON.generate(payload)}\n")
37
+ rescue StandardError
38
+ nil
39
+ end
40
+
41
+ def jsonable(value)
42
+ JSON.parse(JSON.generate(value))
43
+ rescue StandardError
44
+ value.to_s
45
+ end
46
+ end
47
+
48
+ class << self
49
+ attr_accessor :subscribed
50
+
51
+ def enable
52
+ App.log_io ||= MultiIO.new
53
+ App.log_io << $stdout unless App.log_io.include?($stdout)
54
+ subscribe!
55
+ mirror_logger!
56
+ end
57
+
58
+ def disable
59
+ return unless App.log_io.is_a?(MultiIO) && App.log_io.include?($stdout)
60
+
61
+ App.log_io.delete($stdout)
62
+ end
63
+
64
+ def subscribe!
65
+ return if subscribed
66
+ return unless defined?(Mongo::Monitoring::COMMAND) && Mongo::Monitoring::Global.respond_to?(:subscribe)
67
+
68
+ Mongo::Monitoring::Global.subscribe(Mongo::Monitoring::COMMAND, Subscriber)
69
+ self.subscribed = true
70
+ end
71
+
72
+ def mirror_logger!
73
+ return unless defined?(Mongo::Logger)
74
+
75
+ Mongo::Logger.logger = Logger.new(App.log_io)
76
+ end
77
+
78
+ def register_pry_command!
79
+ return if Pry.commands["show-sql"]
80
+
81
+ Pry.commands.block_command("show-sql", "Print Mongo commands") do
82
+ Monq::Commands::ShowSql.enable
83
+ end
84
+
85
+ Pry.commands.block_command("hide-sql", "Stop printing Mongo commands") do
86
+ Monq::Commands::ShowSql.disable
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "commands/info"
4
+ require_relative "commands/models"
5
+ require_relative "commands/show_sql"
6
+ require_relative "commands/reconnect"
7
+ require_relative "commands/redefine"
8
+ require_relative "commands/sandbox"
9
+
10
+ module Monq
11
+ module Commands
12
+ module_function
13
+
14
+ def register_pry_commands!
15
+ return unless defined?(Pry)
16
+
17
+ Info.register_pry_command!
18
+ Models.register_pry_command!
19
+ ShowSql.register_pry_command!
20
+ Reconnect.register_pry_command!
21
+ Redefine.register_pry_command!
22
+ Sandbox.register_pry_command!
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "ostruct"
5
+
6
+ module Monq
7
+ module Config
8
+ RESERVED = %w[default arql].freeze
9
+
10
+ module_function
11
+
12
+ def load_file(path)
13
+ raw = YAML.safe_load(File.read(path), aliases: true)
14
+ (raw || {}).transform_keys(&:to_s)
15
+ rescue ArgumentError
16
+ raw = YAML.safe_load(File.read(path))
17
+ (raw || {}).transform_keys(&:to_s)
18
+ end
19
+
20
+ def select_environments(yaml, names, options)
21
+ yaml = stringify(yaml)
22
+ if names.nil? || names.empty?
23
+ return { "default" => cli_env(options) } if sufficient_cli?(options)
24
+
25
+ raise Error, "No -e given and CLI has no connection. Pass -e or --uri, or create ~/.monq.d/init.yml"
26
+ end
27
+
28
+ names.each do |name|
29
+ raise Error, "#{name} is a reserved environment name" if RESERVED.include?(name)
30
+ raise Error, "Environment #{name} not in config. Known: #{known(yaml).join(', ')}" unless yaml.key?(name)
31
+ end
32
+
33
+ selected = names.to_h { |n| [n, deep_sym(yaml[n])] }
34
+ apply_cli!(selected, options)
35
+ assign_namespaces!(selected)
36
+ assert_unique_namespaces!(selected)
37
+ selected
38
+ end
39
+
40
+ def known(yaml)
41
+ yaml.keys - RESERVED
42
+ end
43
+
44
+ def sufficient_cli?(options)
45
+ opt(options, :uri).to_s != "" || (opt(options, :host).to_s != "" && opt(options, :database).to_s != "")
46
+ end
47
+
48
+ def cli_env(options)
49
+ {
50
+ uri: opt(options, :uri),
51
+ host: opt(options, :host),
52
+ port: opt(options, :port),
53
+ database: opt(options, :database),
54
+ username: opt(options, :username),
55
+ password: opt(options, :password),
56
+ auth_source: opt(options, :auth_source)
57
+ }.compact
58
+ end
59
+
60
+ def apply_cli!(selected, options)
61
+ overrides = connection_overrides(options)
62
+ if selected.size > 1 && overrides.any?
63
+ raise Error, "Connection CLI flags are not allowed with multiple -e environments"
64
+ end
65
+ return if selected.size != 1
66
+
67
+ env = selected.values.first
68
+ overrides.each { |k, v| env[k] = v }
69
+ merge_ssh!(env, options)
70
+ end
71
+
72
+ def connection_overrides(options)
73
+ {
74
+ uri: opt(options, :uri),
75
+ host: opt(options, :host),
76
+ port: opt(options, :port),
77
+ database: opt(options, :database),
78
+ username: opt(options, :username),
79
+ password: opt(options, :password),
80
+ auth_source: opt(options, :auth_source)
81
+ }.compact.reject { |_, v| v.respond_to?(:empty?) && v.empty? }
82
+ end
83
+
84
+ def merge_ssh!(env, options)
85
+ ssh = opt(options, :ssh)
86
+ return if ssh.nil? || ssh.empty?
87
+
88
+ env[:ssh] = (env[:ssh] || {}).merge(ssh.transform_keys(&:to_sym))
89
+ end
90
+
91
+ def assign_namespaces!(selected)
92
+ selected.each do |name, env|
93
+ env[:namespace] ||= camelize(name)
94
+ end
95
+ end
96
+
97
+ def assert_unique_namespaces!(selected)
98
+ groups = selected.group_by { |_, env| env[:namespace] }
99
+ dup = groups.find { |_, pairs| pairs.size > 1 }
100
+ return unless dup
101
+
102
+ raise Error, "Environments share namespace #{dup.first}: #{dup.last.map(&:first).join(', ')}"
103
+ end
104
+
105
+ def camelize(name)
106
+ name.split(/[^A-Za-z0-9]+/).reject(&:empty?).map { |p| p[0].upcase + p[1..] }.join
107
+ end
108
+
109
+ def stringify(hash)
110
+ hash.to_h.transform_keys(&:to_s).transform_values { |v| v.is_a?(Hash) ? stringify(v) : v }
111
+ end
112
+
113
+ def deep_sym(obj)
114
+ case obj
115
+ when Hash then obj.to_h { |k, v| [k.to_sym, deep_sym(v)] }
116
+ else obj
117
+ end
118
+ end
119
+
120
+ def prefix_for(env_name)
121
+ env_name.split(/[._-]/, 2).first
122
+ end
123
+
124
+ def client_name_for(env_name)
125
+ env_name.gsub(/[^A-Za-z0-9]/, "_").to_sym
126
+ end
127
+
128
+ def opt(options, key)
129
+ if options.respond_to?(key)
130
+ options.public_send(key)
131
+ elsif options.respond_to?(:[])
132
+ options[key] || options[key.to_s]
133
+ end
134
+ end
135
+
136
+ def client_options(env)
137
+ env = deep_sym(env)
138
+ ssh = env[:ssh]
139
+ if ssh && env[:uri].to_s.start_with?("mongodb+srv:")
140
+ raise Error, "mongodb+srv with ssh is not supported; use a direct host"
141
+ end
142
+
143
+ built = env[:uri].to_s != "" ? from_uri(env) : from_fields(env)
144
+ return built unless ssh && ssh[:local_port]
145
+
146
+ {
147
+ hosts: ["127.0.0.1:#{ssh[:local_port]}"],
148
+ database: built[:database],
149
+ options: without_connect(without_replica_set(built[:options].merge(direct_connection: true)))
150
+ }
151
+ end
152
+
153
+ def from_uri(env)
154
+ require "mongo"
155
+ parsed = Mongo::URI.get(env[:uri])
156
+ database = env[:database].to_s
157
+ database = parsed.client_options["database"].to_s if database.empty?
158
+ raise Error, "database is required (URI path is empty)" if database.empty?
159
+
160
+ options = symbolize_options(parsed.uri_options)
161
+ creds = parsed.credentials
162
+ options[:user] = creds[:user] if creds[:user]
163
+ options[:password] = creds[:password] if creds[:password]
164
+ options = without_replica_set(options) if env[:ssh] && env[:ssh][:local_port]
165
+ {
166
+ hosts: parsed.servers,
167
+ database: database,
168
+ options: options
169
+ }
170
+ end
171
+
172
+ def from_fields(env)
173
+ raise Error, "host and database are required" if env[:host].to_s.empty? || env[:database].to_s.empty?
174
+
175
+ port = env[:port] || 27017
176
+ options = {}
177
+ options[:user] = env[:username] if env[:username]
178
+ options[:password] = env[:password] if env[:password]
179
+ options[:auth_source] = env[:auth_source] if env[:auth_source]
180
+ options[:replica_set] = env[:replica_set] if env[:replica_set] && !(env[:ssh] && env[:ssh][:local_port])
181
+ options[:ssl] = env[:ssl] unless env[:ssl].nil?
182
+ { hosts: ["#{env[:host]}:#{port}"], database: env[:database], options: options }
183
+ end
184
+
185
+ def mask_uri(uri)
186
+ uri.to_s.sub(%r{://([^:/@]+):[^@]+@}, '://\1:***@')
187
+ end
188
+
189
+ def ssh_forward_target(env)
190
+ env = deep_sym(env)
191
+ if env[:uri].to_s != ""
192
+ require "mongo"
193
+ host, port = Mongo::URI.get(env[:uri]).servers.first.split(":")
194
+ { host: host, port: (port || 27017).to_i }
195
+ else
196
+ { host: env[:host], port: (env[:port] || 27017).to_i }
197
+ end
198
+ end
199
+
200
+ def symbolize_options(options)
201
+ options.to_h { |key, value| [key.to_sym, value] }
202
+ end
203
+
204
+ def without_replica_set(options)
205
+ options.reject { |key, _| replica_set_key?(key) }
206
+ end
207
+
208
+ def without_connect(options)
209
+ options.except(:connect, "connect")
210
+ end
211
+
212
+ def replica_set_key?(key)
213
+ %w[replica_set replicaset].include?(key.to_s.downcase.tr("-", "_"))
214
+ end
215
+ end
216
+ end