repctl 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,638 @@
1
+ require 'mysql2'
2
+ require 'fileutils'
3
+ require 'delegate'
4
+ require 'open3'
5
+
6
+ module Repctl
7
+
8
+ class Client < DelegateClass(Mysql2::Client)
9
+ include Servers
10
+
11
+ @@clients = {}
12
+
13
+ def initialize(instance, opts)
14
+ @instance = instance
15
+ server = server_for_instance(@instance)
16
+ options = {
17
+ :host => server['hostname'],
18
+ :username => "root",
19
+ :port => server['port'],
20
+ :password => Config::ROOT_PASSWORD
21
+ }
22
+ options.delete(:password) if opts[:no_password]
23
+ @client = Mysql2::Client.new(options)
24
+ super(@client)
25
+ end
26
+
27
+ def self.open(instance, opts = {})
28
+ timeout = opts[:timeout] || 10
29
+ opts.delete(:timeout)
30
+ begin
31
+ instance = Integer(instance)
32
+ rescue Mysql2::Error => e
33
+ puts "Instance value <#{instance}> is invalid."
34
+ else
35
+ timeout = Integer(timeout)
36
+ while timeout >= 0
37
+ begin
38
+ @@clients[instance] ||= Client.new(instance, opts)
39
+ # puts "Connected to instance #{instance}."
40
+ break
41
+ rescue Mysql2::Error => e
42
+ puts "#{e.message}, retrying connection to instance #{instance}..."
43
+ # puts e.backtrace
44
+ sleep 1
45
+ timeout -= 1
46
+ end
47
+ end
48
+ end
49
+ @@clients[instance]
50
+ end
51
+
52
+ def close
53
+ @@clients[@instance] = nil
54
+ @client.close
55
+ end
56
+
57
+ def reset
58
+ @client.close
59
+ @@clients[@instance] = nil
60
+ Client.open(@instance)
61
+ end
62
+
63
+ end
64
+
65
+ module Commands
66
+
67
+ include FileUtils
68
+ include Repctl::Config
69
+ include Servers
70
+
71
+ def do_secure_accounts(instance)
72
+ client = Client.open(instance, :no_password => true)
73
+ q1 = "UPDATE mysql.user SET Password = PASSWORD(\'#{ROOT_PASSWORD}\') where User = \'root\'"
74
+ q2 = "UPDATE mysql.user SET Password = PASSWORD(\'#{ROOT_PASSWORD}\') where User = \'\'"
75
+ # q3 = "CREATE USER \'root\'@\'%.#{REPLICATION_DOMAIN}\' IDENTIFIED BY \'#{ROOT_PASSWORD}\'"
76
+ # For testing with clients whose DHCP assigned IP address is not in DNS.
77
+ q3 = "CREATE USER \'root\'@\'%' IDENTIFIED BY \'#{ROOT_PASSWORD}\'"
78
+ q4 = "GRANT ALL PRIVILEGES ON *.* to \'root\'@\'%\' WITH GRANT OPTION"
79
+ q5 = "FLUSH PRIVILEGES"
80
+ if client
81
+ [q1, q2, q3, q4, q5].each do |query|
82
+ puts query
83
+ client.query(query)
84
+ end
85
+ end
86
+ rescue Mysql2::Error => e
87
+ puts e.message
88
+ ensure
89
+ client.close if client
90
+ end
91
+
92
+ def do_start(instance)
93
+ pid = get_mysqld_pid(instance)
94
+ if pid
95
+ puts "Instance #{instance} with PID #{pid} is already running."
96
+ else
97
+ pid = fork
98
+ unless pid
99
+ # We're in the child.
100
+ puts "Starting instance #{instance} with PID #{Process.pid}."
101
+ server = server_for_instance(instance)
102
+
103
+ exec(["#{MYSQL_HOME}/bin/mysqld", "mysqld"],
104
+ "--defaults-file=#{server['defaults-file']}",
105
+ "--datadir=#{server['datadir']}",
106
+ "--port=#{server['port']}",
107
+ "--server-id=#{server['server-id']}",
108
+ "--innodb_data_home_dir=#{server['innodb_data_home_dir']}",
109
+ "--innodb_log_group_home_dir=#{server['innodb_log_group_home_dir']}",
110
+ "--relay-log=#{Socket.gethostname}-relay-bin",
111
+ "--socket=#{server['socket']}",
112
+ "--user=mysql")
113
+ end
114
+ end
115
+ end
116
+
117
+ def do_admin(instance, operation)
118
+ server = server_for_instance(instance)
119
+
120
+ cmd = "#{MYSQL_HOME}/bin/mysqladmin " +
121
+ "--defaults-file=#{server['defaults-file']} " +
122
+ "--user=root " +
123
+ "--host=#{server['hostname']} " +
124
+ "--port=#{server['port']} " +
125
+ "--password=#{ROOT_PASSWORD} " +
126
+ operation
127
+
128
+ pid = get_mysqld_pid(instance)
129
+ if pid
130
+ puts "Running #{operation} on instance #{instance} with pid #{pid}."
131
+ run_cmd(cmd, false)
132
+ else
133
+ puts "Instance #{instance} is not running."
134
+ end
135
+ end
136
+
137
+ def do_config(instance)
138
+ server = server_for_instance(instance)
139
+ FileUtils.rm_rf(server['datadir'])
140
+ cmd = "./scripts/mysql_install_db " +
141
+ "--defaults-file=#{server['defaults-file']} " +
142
+ "--datadir=#{server['datadir']} " +
143
+ "--server-id=#{server['server-id']} " +
144
+ "--innodb_data_home_dir=#{server['innodb_data_home_dir']} " +
145
+ "--innodb_log_group_home_dir=#{server['innodb_log_group_home_dir']} " +
146
+ "--relay-log=#{Socket.gethostname}-relay-bin"
147
+ %x( cd #{MYSQL_HOME} && #{cmd} )
148
+ end
149
+
150
+ def do_status(instance)
151
+
152
+ status = get_coordinates(instance)
153
+ puts status
154
+ end
155
+
156
+ #
157
+ # Treat the instance as a slave and
158
+ # process the output of "SHOW SLAVE STATUS".
159
+ #
160
+ def get_slave_status(instance)
161
+ keys = [
162
+ "Instance",
163
+ "Error",
164
+ "Slave_IO_State",
165
+ "Slave_IO_Running",
166
+ "Slave_SQL_Running",
167
+ "Last_IO_Error",
168
+ "Last_SQL_Error",
169
+ "Seconds_Behind_Master",
170
+ "Master_Log_File",
171
+ "Read_Master_Log_Pos",
172
+ "Relay_Master_Log_File",
173
+ "Exec_Master_Log_Pos",
174
+ "Relay_Log_File",
175
+ "Relay_Log_Pos"
176
+ ]
177
+ results = {}
178
+ status = do_slave_status(instance)
179
+ keys.each do |k|
180
+ results.merge!(k => status[k]) if (status[k] and status[k] != "")
181
+ end
182
+ results
183
+ end
184
+
185
+ def do_crash(instance)
186
+ pid = get_mysqld_pid(instance)
187
+ puts "pid is #{pid}"
188
+ if pid
189
+ puts "Killing mysqld instance #{instance} with PID #{pid}"
190
+ Process.kill("KILL", pid.to_i)
191
+ while get_mysqld_pid(instance)
192
+ puts "in looop"
193
+ sleep 1
194
+ end
195
+ puts "MySQL server instance #{instance.to_i} has been killed."
196
+ else
197
+ puts "MySQL server instance #{instance.to_i} is not running."
198
+ end
199
+ end
200
+
201
+ def is_master?(instance)
202
+ get_slave_coordinates(instance).empty?
203
+ end
204
+
205
+ def is_slave?(instance)
206
+ !is_master?(instance)
207
+ end
208
+
209
+ # This is an example template to create commands to issue queries.
210
+ def do_slave_status(instance)
211
+ client = Client.open(instance)
212
+ if client
213
+ results = client.query("SHOW SLAVE STATUS")
214
+ results.each_with_index do |index, line|
215
+ puts "#{index}: #{line}"
216
+ end
217
+ else
218
+ puts "Could not open connection to MySQL instance #{instance}."
219
+ end
220
+ rescue Mysql2::Error => e
221
+ puts e.message
222
+ ensure
223
+ client.close if client
224
+ end
225
+
226
+ def find_masters()
227
+ masters = []
228
+ all_servers.each do |s|
229
+ masters << s if is_master?(s)
230
+ end
231
+ masters
232
+ end
233
+
234
+ #
235
+ # From http://dev.mysql.com/doc/refman/5.0/en/lock-tables.html:
236
+ #
237
+ # For a filesystem snapshot of innodb, we find that setting
238
+ # innodb_max_dirty_pages_pct to zero; doing a 'flush tables with
239
+ # readlock'; and then waiting for the innodb state to reach 'Main thread
240
+ # process no. \d+, id \d+, state: waiting for server activity' is
241
+ # sufficient to quiesce innodb.
242
+ #
243
+ # You will also need to issue a slave stop if you're backing up a slave
244
+ # whose relay logs are being written to its data directory.
245
+ #
246
+ #
247
+ # select @@innodb_max_dirty_pages_pct;
248
+ # flush tables with read lock;
249
+ # show master status;
250
+ # ...freeze filesystem; do backup...
251
+ # set global innodb_max_dirty_pages_pct = 75;
252
+ #
253
+
254
+ def do_change_master(master, slave, coordinates)
255
+ master_server = server_for_instance(master)
256
+ begin
257
+ slave_connection = Client.open(slave)
258
+ if slave_connection
259
+
260
+ # Replication on the slave can't be running if we want to execute
261
+ # CHANGE MASTER TO.
262
+ slave_connection.query("STOP SLAVE") rescue Mysql2::Error
263
+
264
+ raise "master_server is nil" unless master_server
265
+
266
+ cmd = <<-EOT
267
+ CHANGE MASTER TO
268
+ MASTER_HOST = \'#{master_server['hostname']}\',
269
+ MASTER_PORT = #{master_server['port']},
270
+ MASTER_USER = \'#{REPLICATION_USER}\',
271
+ MASTER_PASSWORD = \'#{REPLICATION_PASSWORD}\',
272
+ MASTER_LOG_FILE = \'#{coordinates[:file]}\',
273
+ MASTER_LOG_POS = #{coordinates[:position]}
274
+ EOT
275
+ puts "Executing: #{cmd}"
276
+ slave_connection.query(cmd)
277
+ else
278
+ puts "do_change_master: Could not connnect to MySQL server."
279
+ end
280
+ rescue Mysql2::Error => e
281
+ puts e.message
282
+ ensure
283
+ slave_connection.close if slave_connection
284
+ end
285
+
286
+ end
287
+
288
+ def do_dump(instance, dumpfile)
289
+ server = server_for_instance(instance)
290
+ coordinates = get_coordinates(instance) do
291
+ cmd = "#{MYSQL_HOME}/bin/mysqldump " +
292
+ "--defaults-file=#{server['defaults-file']} " +
293
+ "--user=root " +
294
+ "--password=#{ROOT_PASSWORD} " +
295
+ "--socket=#{server['socket']} " +
296
+ "--all-databases --lock-all-tables > #{DUMP_DIR}/#{dumpfile}"
297
+ run_cmd(cmd, true)
298
+ end
299
+ coordinates
300
+ end
301
+
302
+ def do_restore(instance, dumpfile)
303
+ server = server_for_instance(instance)
304
+
305
+ # Assumes that the instance is running, but is not acting as a slave.
306
+ cmd = "#{MYSQL_HOME}/bin/mysql " +
307
+ "--defaults-file=#{server['defaults-file']} " +
308
+ "--user=root " +
309
+ "--password=#{ROOT_PASSWORD} " +
310
+ "--socket=#{server['socket']} " +
311
+ "< #{DUMP_DIR}/#{dumpfile}"
312
+ run_cmd(cmd, true)
313
+ end
314
+
315
+ #
316
+ # Get the status of replication for the master and all slaves.
317
+ # Return an array of hashes, each hash has the form:
318
+ # {:instance => <instance id>, :error => <errrmsg>,
319
+ # :master_file => <binlog-file-name>, :master_position => <binlog-position>,
320
+ # :slave_file => <binlog-file-name>, :slave_position => <binlog-position>}
321
+ #
322
+ def do_slave_status(instance)
323
+ instance ||= DEFAULT_MASTER
324
+ locked = false
325
+ client = Client.open(instance, :timeout => 5)
326
+ if client
327
+ client.query("FLUSH TABLES WITH READ LOCK")
328
+ locked = true
329
+ results = client.query("SHOW SLAVE STATUS")
330
+ if results.first
331
+ results.first.merge("Instance" => instance, "Error" => "Success")
332
+ else
333
+ {"Instance" => instance, "Error" => "MySQL server is not a slave."}
334
+ end
335
+ else
336
+ {"Instance" => instance, "Error" => "Could not connect to MySQL server."}
337
+ end
338
+ rescue Mysql2::Error => e
339
+ {:instance => instance, "Error" => e.message}
340
+ ensure
341
+ if client
342
+ client.query("UNLOCK TABLES") if locked
343
+ client.close
344
+ end
345
+ end
346
+
347
+ # Get the master coordinates from a MySQL instance. Optionally,
348
+ # run a block while holding the READ LOCK.
349
+ def get_coordinates(instance)
350
+ instance ||= DEFAULT_MASTER
351
+ locked = false
352
+ client = Client.open(instance)
353
+ if client
354
+ client.query("FLUSH TABLES WITH READ LOCK")
355
+ locked = true
356
+ results = client.query("SHOW MASTER STATUS")
357
+ row = results.first
358
+ coordinates = if row
359
+ {:file => row["File"], :position => row["Position"]}
360
+ else
361
+ {}
362
+ end
363
+ yield coordinates if block_given?
364
+ # You could copy data from the master to the slave at this point
365
+ end
366
+ coordinates
367
+ rescue Mysql2::Error => e
368
+ puts e.message
369
+ # puts e.backtrace
370
+ ensure
371
+ if client
372
+ client.query("UNLOCK TABLES") if locked
373
+ client.close
374
+ end
375
+ # coordinates
376
+ end
377
+
378
+ def get_slave_coordinates(instance)
379
+ client = Client.open(instance)
380
+ if client
381
+ results = client.query("SHOW SLAVE STATUS")
382
+ row = results.first
383
+ if row
384
+ {:file => row["Master_Log_File"], :position => row["Read_Master_Log_Pos"]}
385
+ else
386
+ {}
387
+ end
388
+ end
389
+ ensure
390
+ client.close if client
391
+ end
392
+
393
+ def stop_slave_io_thread(instance)
394
+ client = Client.open(instance)
395
+ if client
396
+ client.query("STOP SLAVE IO_THREAD")
397
+ end
398
+ ensure
399
+ client.close if client
400
+ end
401
+
402
+ def run_mysql_query(instance, cmd)
403
+ client = Client.open(instance)
404
+ if client
405
+ results = client.query(cmd)
406
+ else
407
+ puts "Could not open connection to MySQL instance."
408
+ end
409
+ results
410
+ rescue Mysql2::Error => e
411
+ puts e.message
412
+ puts e.backtrace
413
+ ensure
414
+ client.close if client
415
+ end
416
+
417
+ def start_slave_io_thread(instance)
418
+ client = Client.open(instance)
419
+ if client
420
+ client.query("START SLAVE IO_THREAD")
421
+ end
422
+ ensure
423
+ client.close if client
424
+ end
425
+
426
+ def promote_slave_to_master(instance)
427
+ client = Client.open(instance)
428
+ if client
429
+ client.query("STOP SLAVE")
430
+ client.query("RESET MASTER")
431
+ end
432
+ ensure
433
+ client.close if client
434
+ end
435
+
436
+ def drain_relay_log(instance)
437
+ done = false
438
+ stop_slave_io_thread(instance)
439
+ client = Client.open(instance)
440
+ if client
441
+
442
+ # If the slave 'sql_thread' is not running, this will loop forever.
443
+ while !done
444
+ results = client.query("SHOW PROCESSLIST")
445
+ results.each do |row|
446
+ if row["State"] =~ /Slave has read all relay log/
447
+ done = true
448
+ puts "Slave has read all relay log."
449
+ break
450
+ end
451
+ end
452
+ puts "Waiting for slave to read relay log." unless done
453
+ end
454
+ else
455
+ puts "Could not open connection to instance #{instance}."
456
+ end
457
+ ensure
458
+ client.close if client
459
+ end
460
+
461
+ # 'master' is the new master
462
+ # 'slaves' is contains the list of slaves, one of these may be the current master.
463
+ def switch_master_to(master, slaves)
464
+
465
+ slaves = Array(slaves)
466
+
467
+ # Step 1. Make sure all slaves have completely processed their
468
+ # Relay Log.
469
+ slaves.each do |s|
470
+ puts "Draining relay log for slave instance #{s}"
471
+ drain_relay_log(s) if is_slave?(s)
472
+ end
473
+
474
+ # Step 2. For the slave being promoted to master, issue STOP SLAVE
475
+ # and RESET MASTER.
476
+ client = Client.open(master)
477
+ client.query("STOP SLAVE")
478
+ client.query("RESET MASTER")
479
+ client.close
480
+
481
+ # Step 3. Change the master for the other slaves (and the former master?)
482
+ # XXX this is not complete -- what about the coordinates?
483
+ master_server = server_for_instance(master)
484
+ master_host = 'localhost' # should be master_server[:hostname]
485
+ master_user = 'repl'
486
+ master_password = 'har526'
487
+ master_port = master_server[:port]
488
+ slaves.each do |s|
489
+ client = Client.open(s)
490
+ cmd = <<-EOT
491
+ CHANGE MASTER TO
492
+ MASTER_HOST=\'#{master_host}\',
493
+ MASTER_PORT=#{master_port},
494
+ MASTER_USER=\'#{master_user}\',
495
+ MASTER_PASSWORD=\'#{master_password}\'
496
+ EOT
497
+ client.query(cmd)
498
+ client.query("START SLAVE")
499
+ client.close
500
+ end
501
+ end
502
+
503
+ def do_repl_user(instance)
504
+ hostname = "127.0.0.1"
505
+ client = Client.open(instance)
506
+ cmd = "DROP USER \'#{REPLICATION_USER}\'@\'#{hostname}\'"
507
+ client.query(cmd) rescue Mysql2::Error
508
+
509
+ if client
510
+ # "CREATE USER \'#{REPLICATION_USER\'@'%.thirdmode.com' IDENTIFIED BY \'#{REPLICATION_PASSWORD}\'"
511
+ # "GRANT REPLICATION SLAVE ON *.* TO \'#{REPLICATON_USER}\'@\'%.#{REPLICATION_DOMAIN}\'"
512
+ cmd = "CREATE USER \'#{REPLICATION_USER}\'@\'#{hostname}\' IDENTIFIED BY \'#{REPLICATION_PASSWORD}\'"
513
+ puts cmd
514
+ client.query(cmd)
515
+ cmd = "GRANT REPLICATION SLAVE ON *.* TO \'#{REPLICATION_USER}\'@\'#{hostname}\'"
516
+ puts cmd
517
+ client.query(cmd)
518
+ client.query("FLUSH PRIVILEGES")
519
+ else
520
+ puts "Could not open connection to MySQL instance #{instance}."
521
+ end
522
+ rescue Mysql2::Error => e
523
+ puts e.message
524
+ puts e.backtrace
525
+ ensure
526
+ client.close if client
527
+ end
528
+
529
+
530
+ def do_cluster_user(instance)
531
+ client = Client.open(instance)
532
+
533
+ cmd = "DROP USER \'cluster\'@\'localhost\'"
534
+ client.query(cmd) rescue Mysql2::Error
535
+
536
+ cmd = "DROP USER \'cluster\'@\'%\'"
537
+ client.query(cmd) rescue Mysql2::Error
538
+
539
+ if client
540
+ cmd = "CREATE USER \'cluster\'@\'localhost\' IDENTIFIED BY \'secret\'"
541
+ client.query(cmd)
542
+ cmd = "GRANT ALL PRIVILEGES ON *.* TO \'cluster\'@'\localhost\'"
543
+ client.query(cmd)
544
+ cmd = "CREATE USER \'cluster\'@\'%\' IDENTIFIED BY \'secret\'"
545
+ client.query(cmd)
546
+ cmd = "GRANT ALL PRIVILEGES ON *.* TO \'cluster\'@\'%\'"
547
+ client.query(cmd)
548
+ else
549
+ puts "Could not open connection to MySQL instance #{instance}."
550
+ end
551
+ rescue Mysql2::Error => e
552
+ puts e.message
553
+ puts e.backtrace
554
+ ensure
555
+ client.close if client
556
+ end
557
+
558
+ # Create the 'widgets' database.
559
+ def do_create_widgets(instance)
560
+ client = Client.open(instance)
561
+ if client
562
+ client.query("drop database if exists widgets")
563
+ client.query("create database widgets")
564
+ else
565
+ puts "Could not open connection to MySQL instance #{instance}."
566
+ end
567
+ rescue Mysql2::Error => e
568
+ puts e.message
569
+ puts e.backtrace
570
+ ensure
571
+ client.close if client
572
+ end
573
+
574
+ # This is an example template to create commands to issue queries.
575
+ def template(instance)
576
+ client = Client.open(instance)
577
+ if client
578
+ client.query("some SQL statement")
579
+ else
580
+ puts "Could not open connection to MySQL instance #{instance}."
581
+ end
582
+ rescue Mysql2::Error => e
583
+ puts e.message
584
+ puts e.backtrace
585
+ ensure
586
+ client.close if client
587
+ end
588
+
589
+ private
590
+
591
+ def run_cmd(cmd, verbose)
592
+ puts cmd if verbose
593
+ cmd += " > /dev/null 2>&1" unless verbose
594
+ output = %x[#{cmd}]
595
+ puts output if verbose
596
+ exit_code = $?.exitstatus
597
+ if exit_code == 0
598
+ puts "OK"
599
+ else
600
+ "FAIL: exit code is #{exit_code}"
601
+ end
602
+ end
603
+
604
+ # Return the process ID (pid) for an instance.
605
+ def get_mysqld_pid(instance)
606
+ server = server_for_instance(instance)
607
+ pidfile = server['pid-file']
608
+ return nil unless File.exist?(pidfile)
609
+ Integer(File.open(pidfile, &:readline).strip)
610
+ end
611
+ end
612
+ end
613
+
614
+ # STOP SLAVE
615
+ # RESET MASTER
616
+ # CHANGE MASTER TO
617
+
618
+ class RunExamples
619
+ include Repctl::Commands
620
+
621
+ def runtest
622
+
623
+ switch_master_to(3)
624
+ =begin
625
+ (1..4).each {|i| ensure_running(i)}
626
+ puts get_master_coordinates(1)
627
+ (2..4).each {|i| puts get_slave_coordinates(i)}
628
+ (1..4).each {|i| puts is_master?(i) }
629
+ puts "Master is #{find_master}"
630
+ drain_relay_log(2)
631
+ # sleep(10)
632
+ # (1..4).each {|i| crash(i)}
633
+ =end
634
+ end
635
+ end
636
+
637
+
638
+
@@ -0,0 +1,48 @@
1
+ require 'yaml'
2
+
3
+ module Repctl
4
+
5
+ module Servers
6
+
7
+ include Config
8
+
9
+ def all_servers
10
+ @servers ||= File.open(SERVER_CONFIG) { |yf| YAML::load( yf ) }
11
+ end
12
+
13
+ def all_instances
14
+ instances = []
15
+ all_servers.each do |s|
16
+ instances << s["instance"]
17
+ end
18
+ instances
19
+ end
20
+
21
+ def server_for_instance(instance)
22
+ all_servers.select {|s| s["instance"] == Integer(instance)}.shift
23
+ end
24
+
25
+ def all_live_servers
26
+ all_servers.select {|s| get_mysqld_pid(s["instance"]) }
27
+ end
28
+
29
+ def all_live_instances
30
+ all_live_servers.map {|s| s["instance"]}
31
+ end
32
+
33
+ def live?(instance)
34
+ get_mysqld_pid(instance)
35
+ end
36
+
37
+ private
38
+
39
+ # Return the process ID (pid) for an instance.
40
+ def get_mysqld_pid(instance)
41
+ server = server_for_instance(instance)
42
+ pidfile = server['pid-file']
43
+ return nil unless File.exist?(pidfile)
44
+ Integer(File.open(pidfile, &:readline).strip)
45
+ end
46
+
47
+ end
48
+ end
@@ -0,0 +1,3 @@
1
+ module Repctl
2
+ VERSION = "0.0.1"
3
+ end
data/lib/repctl.rb ADDED
@@ -0,0 +1,9 @@
1
+ require File.expand_path('../../config/config', __FILE__)
2
+
3
+ require "repctl/version"
4
+ require "repctl/servers"
5
+ require "repctl/mysql_admin"
6
+
7
+ module Repctl
8
+ # Your code goes here...
9
+ end