rise-mirror_manager 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.
- checksums.yaml +7 -0
- data/.gitignore +12 -0
- data/.rspec +3 -0
- data/.rubocop.yml +2 -0
- data/.travis.yml +5 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +79 -0
- data/LICENSE.txt +21 -0
- data/README.md +27 -0
- data/Rakefile +6 -0
- data/bin/console +15 -0
- data/bin/mirror_manager +20 -0
- data/bin/setup +8 -0
- data/config/initializers/production.rb +34 -0
- data/config/initializers/test.rb +40 -0
- data/data/mirror_data.ini +29 -0
- data/db/mirror_prod.db +0 -0
- data/db/mirror_test.db +0 -0
- data/lib/rise/mirror_manager.rb +79 -0
- data/lib/rise/mirror_manager/configuration.rb +17 -0
- data/lib/rise/mirror_manager/database.rb +49 -0
- data/lib/rise/mirror_manager/error/cmd_error.rb +15 -0
- data/lib/rise/mirror_manager/error/custom_error.rb +19 -0
- data/lib/rise/mirror_manager/error/error_handler.rb +23 -0
- data/lib/rise/mirror_manager/error/lock_error.rb +14 -0
- data/lib/rise/mirror_manager/logging.rb +60 -0
- data/lib/rise/mirror_manager/mirror.rb +46 -0
- data/lib/rise/mirror_manager/notification.rb +46 -0
- data/lib/rise/mirror_manager/sync.rb +196 -0
- data/lib/rise/mirror_manager/sync_status.rb +10 -0
- data/lib/rise/mirror_manager/version.rb +5 -0
- data/rise-mirror_manager.gemspec +43 -0
- data/rspec_expl +61 -0
- metadata +231 -0
@@ -0,0 +1,17 @@
|
|
1
|
+
module Rise
|
2
|
+
module MirrorManager
|
3
|
+
class << self
|
4
|
+
attr_accessor :configuration
|
5
|
+
end
|
6
|
+
|
7
|
+
def self.configure
|
8
|
+
self.configuration ||= Configuration.new
|
9
|
+
yield(configuration)
|
10
|
+
end
|
11
|
+
|
12
|
+
# Class containing application configuration
|
13
|
+
class Configuration
|
14
|
+
attr_accessor :log_dir, :admin_email, :mirror_data_file, :database, :slack_webhook
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
require 'sqlite3'
|
2
|
+
|
3
|
+
module Rise
|
4
|
+
module MirrorManager
|
5
|
+
# Module for database interaction
|
6
|
+
module Database
|
7
|
+
DB = SQLite3::Database.open(Rise::MirrorManager.configuration.database, results_as_hash: true)
|
8
|
+
|
9
|
+
def get_sync_status(mirror_name)
|
10
|
+
columns = %w[sync_status total_bandwidth error]
|
11
|
+
row = DB.execute("SELECT #{columns.join(',')} FROM sync_info WHERE mirror_id = (SELECT mirror_id FROM mirrors WHERE name = ?)", [mirror_name])[0] #Retrieve first row of the Array
|
12
|
+
if row.nil? || row.empty?
|
13
|
+
nil
|
14
|
+
else
|
15
|
+
return row.select { |key, _| columns.include?(key.to_s) }
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def update_sync_status(mirror_name:, status:, total_bandwidth: nil, error: nil)
|
20
|
+
mirror_id = retrieve_mirror_id(mirror_name)
|
21
|
+
if sync_info_exists?(mirror_id)
|
22
|
+
DB.execute 'UPDATE sync_info SET sync_status = ?, total_bandwidth = ?, error = ? WHERE mirror_id = ?', [status, total_bandwidth, error, mirror_id]
|
23
|
+
else
|
24
|
+
DB.execute 'INSERT INTO sync_info (mirror_id, sync_status, total_bandwidth, error) VALUES (?, ?, ?, ?)', [mirror_id, status, total_bandwidth, error]
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def retrieve_mirror_id(mirror_name)
|
31
|
+
row = (DB.execute 'SELECT mirror_id FROM mirrors WHERE name = ?', [mirror_name])[0]
|
32
|
+
if row.nil? || row.empty?
|
33
|
+
raise "#{mirror_name} not in database."
|
34
|
+
else
|
35
|
+
row["mirror_id"]
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def sync_info_exists?(mirror_id)
|
40
|
+
sync_info = DB.execute 'SELECT sync_info_id FROM sync_info WHERE mirror_id = ?', [mirror_id]
|
41
|
+
if sync_info.nil? || sync_info.empty?
|
42
|
+
false
|
43
|
+
else
|
44
|
+
true
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
require_relative 'custom_error'
|
2
|
+
|
3
|
+
module Rise
|
4
|
+
module MirrorManager
|
5
|
+
module Error
|
6
|
+
# Custom exception class for cmd failures
|
7
|
+
class CmdError < CustomError
|
8
|
+
def initialize(msg, original = $ERROR_INFO)
|
9
|
+
super(:cmd_error, msg)
|
10
|
+
@original = original
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module Rise
|
2
|
+
module MirrorManager
|
3
|
+
module Error
|
4
|
+
# Class for RISE custom error exception
|
5
|
+
class CustomError < StandardError
|
6
|
+
attr_reader :status, :error, :message
|
7
|
+
|
8
|
+
def initialize(error = nil, msg = nil)
|
9
|
+
@error = error || :generic_error
|
10
|
+
@message = msg || 'Something went wrong'
|
11
|
+
end
|
12
|
+
|
13
|
+
def log_error
|
14
|
+
# log error
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module Rise
|
2
|
+
module MirrorManager
|
3
|
+
module Error
|
4
|
+
module ErrorHandler
|
5
|
+
|
6
|
+
def self.included(clazz)
|
7
|
+
clazz.class_eval do
|
8
|
+
rescue_from CmdError do |e|
|
9
|
+
#log error
|
10
|
+
end
|
11
|
+
rescue_from StandardError do |e|
|
12
|
+
#log error
|
13
|
+
end
|
14
|
+
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
|
@@ -0,0 +1,60 @@
|
|
1
|
+
require_relative 'configuration'
|
2
|
+
require 'logger'
|
3
|
+
|
4
|
+
module Rise
|
5
|
+
module MirrorManager
|
6
|
+
# Module containing logging functionalities
|
7
|
+
module Logging
|
8
|
+
class << self
|
9
|
+
attr_reader :app_logger
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.setup_logger
|
13
|
+
log_file = File.new(File.join(Rise::MirrorManager.configuration.log_dir, 'mirror_manager.log'), File::CREAT | File::WRONLY | File::APPEND)
|
14
|
+
log_file.sync = true
|
15
|
+
app_logger = Logger.new(log_file, 'weekly',
|
16
|
+
formatter: proc { |severity, datetime, _progname, msg|
|
17
|
+
"[#{Process.pid}][#{datetime}] #{severity} -- #{msg}\n"
|
18
|
+
})
|
19
|
+
end
|
20
|
+
|
21
|
+
# class IOToLog < IO
|
22
|
+
# def initialize(logger)
|
23
|
+
# @logger = logger
|
24
|
+
# end
|
25
|
+
|
26
|
+
# def write(message)
|
27
|
+
# @logger.error message
|
28
|
+
# end
|
29
|
+
# end
|
30
|
+
|
31
|
+
# Logger object for individual sync jobs
|
32
|
+
class SyncLog
|
33
|
+
def initialize(filename)
|
34
|
+
log_file = File.open(File.join(Rise::MirrorManager.configuration.log_dir,
|
35
|
+
"#{filename}.log"), File::CREAT | File::WRONLY | File::APPEND)
|
36
|
+
log_file.sync = true # flush logs in realtime
|
37
|
+
@logger = Logger.new(log_file, 'weekly', formatter: proc { |severity, datetime, _progname, msg|
|
38
|
+
"[#{Process.pid}:#{Thread.current.object_id}][#{datetime}] #{severity} -- #{msg}\n"
|
39
|
+
})
|
40
|
+
end
|
41
|
+
|
42
|
+
def log_info(msg)
|
43
|
+
logger.info msg
|
44
|
+
end
|
45
|
+
|
46
|
+
def log_warn(msg)
|
47
|
+
logger.warn msg
|
48
|
+
end
|
49
|
+
|
50
|
+
def log_error(msg)
|
51
|
+
logger.error msg
|
52
|
+
end
|
53
|
+
|
54
|
+
private
|
55
|
+
|
56
|
+
attr_reader :logger
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
require_relative 'configuration'
|
2
|
+
require_relative 'logging'
|
3
|
+
require 'ostruct'
|
4
|
+
require 'inifile'
|
5
|
+
|
6
|
+
module Rise
|
7
|
+
module MirrorManager
|
8
|
+
# Module for Mirror data
|
9
|
+
module Mirror
|
10
|
+
def self.retrieve_mirrors(interval)
|
11
|
+
mirrors = extract_mirrors(Rise::MirrorManager.configuration.mirror_data_file, interval)
|
12
|
+
if mirrors.empty?
|
13
|
+
raise "No mirror was retrieved for interval: #{interval}."
|
14
|
+
end
|
15
|
+
mirrors
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.extract_mirrors(mirror_data_file, interval)
|
19
|
+
data_file = IniFile.load(mirror_data_file)
|
20
|
+
if data_file.nil?
|
21
|
+
raise "#{mirror_data_file} not found."
|
22
|
+
end
|
23
|
+
mirrors = []
|
24
|
+
data_file.each_section do |section|
|
25
|
+
next if data_file[section]['INTERVAL'] != interval
|
26
|
+
mirror = OpenStruct.new(name: data_file[section]['NAME'])
|
27
|
+
if data_file[section]['CUSTOM_SCRIPT']
|
28
|
+
mirror[:custom_script] = data_file[section]['CUSTOM_SCRIPT']
|
29
|
+
else
|
30
|
+
mirror[:remote_source] = data_file[section]['REMOTE_SOURCE']
|
31
|
+
mirror[:local_dir] = data_file[section]['LOCAL_DIR']
|
32
|
+
mirror[:rsync_opts] = data_file[section]['RSYNC_OPTS']
|
33
|
+
mirror[:rsync_del_opts] = data_file[section]['RSYNC_DEL_OPTS']
|
34
|
+
mirror[:exclude] = data_file[section]['EXCLUDE']
|
35
|
+
mirror[:include] = data_file[section]['INCLUDE']
|
36
|
+
mirror[:filter] = data_file[section]['FILTER']
|
37
|
+
mirror[:precmd] = data_file[section]['PRECMD']
|
38
|
+
mirror[:postcmd] = data_file[section]['POSTCMD']
|
39
|
+
mirror[:depth] = data_file[section]['DEPTH']
|
40
|
+
end
|
41
|
+
mirrors << mirror
|
42
|
+
end
|
43
|
+
mirrors
|
44
|
+
end end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
require_relative 'configuration'
|
2
|
+
require 'pony'
|
3
|
+
require 'slack/incoming/webhooks'
|
4
|
+
|
5
|
+
module Rise
|
6
|
+
module MirrorManager
|
7
|
+
# Module containing notification functionalities
|
8
|
+
module Notification
|
9
|
+
SLACK = Slack::Incoming::Webhooks.new Rise::MirrorManager.configuration.slack_webhook
|
10
|
+
|
11
|
+
def send_email(msg)
|
12
|
+
Pony.mail({
|
13
|
+
:to => Rise::MirrorManager.configuration.admin_email,
|
14
|
+
:from => "MirrorManager@#{Socket.gethostname}",
|
15
|
+
:via => :smtp,
|
16
|
+
:via_options => {
|
17
|
+
:address => 'aspmx.l.google.com',
|
18
|
+
:port => 25,
|
19
|
+
:domain => Socket.gethostname
|
20
|
+
},
|
21
|
+
:subject => "[#{Socket.gethostname}]MirrorManager Notification @ #{(Time.now).strftime("[%Y-%m-%d]%A, %H:%M")}",
|
22
|
+
:body => msg
|
23
|
+
})
|
24
|
+
end
|
25
|
+
|
26
|
+
def send_slack_message(mirror_name:, error:, runtime:)
|
27
|
+
attachment = {
|
28
|
+
fallback: "#{mirror_name} has failed syncing @ #{runtime}. Error: #{error}",
|
29
|
+
color: "#FF0000",
|
30
|
+
"fields": [
|
31
|
+
{
|
32
|
+
"title": "#{mirror_name} Failed",
|
33
|
+
"value": "#{error}"
|
34
|
+
}
|
35
|
+
],
|
36
|
+
"footer": "#{runtime}"
|
37
|
+
}
|
38
|
+
|
39
|
+
SLACK.post '', attachments: [attachment]
|
40
|
+
puts 'hmmm'
|
41
|
+
# https://hooks.slack.com/services/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
|
42
|
+
# TODO: IMplement
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,196 @@
|
|
1
|
+
# require File.join(__dir__, '../../../config/initializers/production')
|
2
|
+
require_relative 'error/cmd_error'
|
3
|
+
require_relative 'logging'
|
4
|
+
require_relative 'database'
|
5
|
+
require_relative 'sync_status'
|
6
|
+
require 'marz/rsync'
|
7
|
+
require 'English'
|
8
|
+
require 'tmpdir'
|
9
|
+
require 'ostruct'
|
10
|
+
require 'open3'
|
11
|
+
|
12
|
+
module Rise
|
13
|
+
module MirrorManager
|
14
|
+
# This will create an instance
|
15
|
+
# for every mirror rsync job
|
16
|
+
class Sync
|
17
|
+
include Rise::MirrorManager::Error
|
18
|
+
include Rise::MirrorManager::Logging
|
19
|
+
include Rise::MirrorManager::SyncStatus
|
20
|
+
include Rise::MirrorManager::Database
|
21
|
+
|
22
|
+
DEFAULT_RSYNC_OPTIONS = '-avzuH --no-motd --safe-links --numeric-ids' \
|
23
|
+
' --delay-updates --timeout=600 --contimeout=300'.freeze
|
24
|
+
DEFAULT_RSYNC_DELETE_OPTIONS = '--delete --delete-delay'.freeze
|
25
|
+
|
26
|
+
def self.run(mirror)
|
27
|
+
Sync.new(mirror).run
|
28
|
+
end
|
29
|
+
|
30
|
+
def run
|
31
|
+
err = catch :failure do
|
32
|
+
if mirror.custom_script
|
33
|
+
execute_cmd(mirror.custom_script) { update_sync_status(mirror_name: mirror.name, status: SUCCESS) }
|
34
|
+
# execute_custom_script(mirror.custom_script)
|
35
|
+
else
|
36
|
+
begin
|
37
|
+
# err = catch :failure do
|
38
|
+
@rsync_opts = build_rsync_opts(mirror.rsync_opts, mirror.rsync_delete_opts)
|
39
|
+
# Can't capture the error `File.join(mirror.local_dir, "#{mirror.name}.lock")` when mirror.local_dir == nil
|
40
|
+
@lock_file = File.join(mirror.local_dir, "#{mirror.name}.lock")
|
41
|
+
establish_lock
|
42
|
+
# execute_precmd(mirror.pre_cmd) if mirror.pre_cmd
|
43
|
+
execute_cmd(mirror.pre_cmd) if mirror.pre_cmd
|
44
|
+
sync
|
45
|
+
# execute_postcmd(mirror.post_cmd) if mirror.post_cmd
|
46
|
+
execute_cmd(mirror.post_cmd) if mirror.post_cmd
|
47
|
+
update_sync_status(mirror_name: mirror.name, status: SUCCESS, total_bandwidth: total_bandwidth)
|
48
|
+
ensure
|
49
|
+
release_lock
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
update_sync_status(mirror_name: mirror.name, status: FAILED, error_msg: err) if err
|
54
|
+
end
|
55
|
+
|
56
|
+
private
|
57
|
+
|
58
|
+
def initialize(mirror)
|
59
|
+
@mirror = mirror
|
60
|
+
@logger = SyncLog.new(mirror.name)
|
61
|
+
end
|
62
|
+
|
63
|
+
|
64
|
+
attr_reader :mirror, :lock_file, :rsync_opts, :logger, :total_bandwidth
|
65
|
+
|
66
|
+
def sync
|
67
|
+
Marz::Rsync.run(mirror.remote_source, mirror.local_dir, rsync_opts) do |result|
|
68
|
+
if result.success?
|
69
|
+
@total_bandwidth = convert_bytes(result.total_bytes_sent + result.total_bytes_received)
|
70
|
+
logger.log_info result.output
|
71
|
+
else
|
72
|
+
logger.log_error result.output
|
73
|
+
throw :failure, result.output
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
def execute_cmd(cmd)
|
79
|
+
Open3.popen2e(cmd.to_s) do |_i, oe, t|
|
80
|
+
unless t.value.success?
|
81
|
+
throw :failure, oe.read
|
82
|
+
end
|
83
|
+
end
|
84
|
+
yield if block_given?
|
85
|
+
end
|
86
|
+
|
87
|
+
# def execute_precmd(cmd)
|
88
|
+
# Open3.popen2e(cmd.to_s) do |_i, oe, t|
|
89
|
+
# unless t.value.success?
|
90
|
+
# update_precmd_result(mirror.name, FAILED)
|
91
|
+
# msg = oe.read # Can only be retrieved/read once
|
92
|
+
# logger.log_error(msg) unless msg.nil? || msg.empty?
|
93
|
+
# exit
|
94
|
+
# end
|
95
|
+
# update_precmd_result(mirror.name, SUCCESS)
|
96
|
+
# end
|
97
|
+
# rescue Errno::ENOENT => e
|
98
|
+
# update_precmd_result(mirror.name, FAILED)
|
99
|
+
# logger.log_error(e.message) unless e.message.nil? || e.message.empty?
|
100
|
+
# exit
|
101
|
+
# end
|
102
|
+
|
103
|
+
# def execute_postcmd(cmd)
|
104
|
+
# Open3.popen2e(cmd.to_s) do |_i, oe, t|
|
105
|
+
# unless t.value.success?
|
106
|
+
# update_postcmd_result(mirror.name, FAILED)
|
107
|
+
# msg = oe.read # Can only be retrieved/read once
|
108
|
+
# logger.log_error(msg) unless msg.nil? || msg.empty?
|
109
|
+
# exit
|
110
|
+
# end
|
111
|
+
# update_postcmd_result(mirror.name, SUCCESS)
|
112
|
+
# end
|
113
|
+
# rescue Errno::ENOENT => e
|
114
|
+
# update_postcmd_result(mirror.name, FAILED)
|
115
|
+
# logger.log_error(e.message) unless e.message.nil? || e.message.empty?
|
116
|
+
# exit
|
117
|
+
# end
|
118
|
+
|
119
|
+
# def execute_custom_script(cmd)
|
120
|
+
# execute_cmd(cmd) do |_oe, t|
|
121
|
+
# update_sync_status(mirror.name, SUCCESS) if t.value.success?
|
122
|
+
# end
|
123
|
+
# end
|
124
|
+
|
125
|
+
# def execute_cmd(cmd)
|
126
|
+
# Open3.popen2e(cmd.to_s) do |_i, oe, t|
|
127
|
+
# yield(oe, t) if block_given?
|
128
|
+
# process_sync_failure(oe.read) unless t.value.success?
|
129
|
+
# end
|
130
|
+
# rescue Errno::ENOENT => e
|
131
|
+
# process_sync_failure(e.message)
|
132
|
+
# end
|
133
|
+
|
134
|
+
def process_sync_failure(msg = nil)
|
135
|
+
update_sync_status(mirror.name, FAILED)
|
136
|
+
logger.log_error(msg) unless msg.nil? || msg.empty?
|
137
|
+
exit
|
138
|
+
end
|
139
|
+
|
140
|
+
def establish_lock
|
141
|
+
if File.exist?(lock_file)
|
142
|
+
acquire_lock
|
143
|
+
else
|
144
|
+
create_lock
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
def acquire_lock
|
149
|
+
pid = File.read(lock_file).to_i
|
150
|
+
Process.kill(0, pid)
|
151
|
+
rescue Errno::ESRCH
|
152
|
+
File.delete(lock_file) # nonexistent process
|
153
|
+
create_lock
|
154
|
+
rescue Errno::EPERM
|
155
|
+
throw :failure, "No permission/privilege to send signal to process(#{pid})"
|
156
|
+
else
|
157
|
+
throw :failure, "Cannot establish lock. Another process(#{pid}) is currently running and holding the lock."
|
158
|
+
end
|
159
|
+
|
160
|
+
def create_lock
|
161
|
+
File.open(lock_file, File::RDWR | File::CREAT, 0o644) do |f|
|
162
|
+
f.flock(File::LOCK_EX)
|
163
|
+
f.write(Process.pid.to_s)
|
164
|
+
f.flush
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
def release_lock
|
169
|
+
File.delete(lock_file) if File.exist?(lock_file)
|
170
|
+
end
|
171
|
+
|
172
|
+
def build_rsync_opts(rsync_opts, rsync_delete_opts)
|
173
|
+
opts = rsync_opts || DEFAULT_RSYNC_OPTIONS
|
174
|
+
opts += ' ' + (rsync_delete_opts || DEFAULT_RSYNC_DELETE_OPTIONS)
|
175
|
+
opts.split(' ')
|
176
|
+
end
|
177
|
+
|
178
|
+
def convert_bytes(bytes)
|
179
|
+
bytes = Integer(bytes)
|
180
|
+
result =
|
181
|
+
if bytes <= 104_857_0
|
182
|
+
format('%.2fK', (bytes / 1024.0))
|
183
|
+
elsif bytes <= 107_373_645_5 # 0.99GB
|
184
|
+
format('%.2fM', ((bytes / 1024.0) / 1024.0))
|
185
|
+
else
|
186
|
+
format('%.2fG', (((bytes / 1024.0) / 1024.0) / 1024.0))
|
187
|
+
end
|
188
|
+
result.gsub(/(\.)0+/, '')
|
189
|
+
end
|
190
|
+
|
191
|
+
end
|
192
|
+
end
|
193
|
+
end
|
194
|
+
|
195
|
+
# mirror = OpenStruct.new(name:"Delete Me", remote_source:"/tmp/srv/mirror/delete-me-source", local_dir:"/tmp/srv/mirror/delete-me-dest", rsync_opts:'-avzuH --no-motd --safe-links --numeric-ids --delay-updates --timeout=600', rsync_del_opts:nil, exclude:nil, include:nil, filter:nil, precmd:nil, postcmd:nil, depth:1)
|
196
|
+
# Rise::MirrorManager::Sync.new(mirror).run
|