shuttle-deploy 0.2.0.beta1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +15 -0
- data/.gitignore +25 -0
- data/.magnum.yml +1 -0
- data/.rspec +2 -0
- data/Gemfile +3 -0
- data/LICENSE +18 -0
- data/README.md +230 -0
- data/Rakefile +10 -0
- data/bin/shuttle +60 -0
- data/examples/rails.yml +22 -0
- data/examples/static.yml +10 -0
- data/examples/wordpress.yml +34 -0
- data/lib/shuttle/config.rb +5 -0
- data/lib/shuttle/deploy.rb +58 -0
- data/lib/shuttle/deployment/nodejs.rb +48 -0
- data/lib/shuttle/deployment/php.rb +17 -0
- data/lib/shuttle/deployment/rails.rb +116 -0
- data/lib/shuttle/deployment/ruby.rb +40 -0
- data/lib/shuttle/deployment/static.rb +5 -0
- data/lib/shuttle/deployment/wordpress/cli.rb +27 -0
- data/lib/shuttle/deployment/wordpress/core.rb +50 -0
- data/lib/shuttle/deployment/wordpress/plugins.rb +112 -0
- data/lib/shuttle/deployment/wordpress/vip.rb +84 -0
- data/lib/shuttle/deployment/wordpress.rb +191 -0
- data/lib/shuttle/errors.rb +5 -0
- data/lib/shuttle/helpers.rb +50 -0
- data/lib/shuttle/runner.rb +153 -0
- data/lib/shuttle/session.rb +52 -0
- data/lib/shuttle/support/bundler.rb +45 -0
- data/lib/shuttle/support/foreman.rb +7 -0
- data/lib/shuttle/support/thin.rb +59 -0
- data/lib/shuttle/target.rb +23 -0
- data/lib/shuttle/tasks.rb +264 -0
- data/lib/shuttle/version.rb +3 -0
- data/lib/shuttle.rb +35 -0
- data/shuttle-deploy.gemspec +28 -0
- data/spec/deploy_spec.rb +4 -0
- data/spec/fixtures/.gitkeep +0 -0
- data/spec/fixtures/static.yml +11 -0
- data/spec/helpers_spec.rb +42 -0
- data/spec/spec_helper.rb +15 -0
- data/spec/target_spec.rb +41 -0
- metadata +232 -0
@@ -0,0 +1,40 @@
|
|
1
|
+
module Shuttle
|
2
|
+
class Ruby < Shuttle::Deploy
|
3
|
+
include Shuttle::Support::Bundler
|
4
|
+
include Shuttle::Support::Thin
|
5
|
+
|
6
|
+
def setup
|
7
|
+
unless ruby_installed?
|
8
|
+
error "Please install Ruby first"
|
9
|
+
end
|
10
|
+
|
11
|
+
unless bundle_installed?
|
12
|
+
install_bundler
|
13
|
+
end
|
14
|
+
|
15
|
+
super
|
16
|
+
end
|
17
|
+
|
18
|
+
def deploy
|
19
|
+
setup
|
20
|
+
update_code
|
21
|
+
checkout_code
|
22
|
+
bundle_install
|
23
|
+
thin_restart
|
24
|
+
link_shared_paths
|
25
|
+
link_release
|
26
|
+
end
|
27
|
+
|
28
|
+
def link_shared_paths
|
29
|
+
ssh.run("mkdir -p #{release_path('tmp')}")
|
30
|
+
ssh.run("ln -s #{shared_path('pids')} #{release_path('tmp/pids')}")
|
31
|
+
ssh.run("ln -s #{shared_path('log')} #{release_path('log')}")
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def ruby_installed?
|
37
|
+
ssh.run("which ruby").success?
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
module Shuttle
|
2
|
+
module WordpressCli
|
3
|
+
CLI_GIT = 'https://github.com/wp-cli/wp-cli.git'
|
4
|
+
CLI_PATH = '/usr/local/share/wp-cli'
|
5
|
+
|
6
|
+
# Check if CLI is installed
|
7
|
+
# @return [Boolean]
|
8
|
+
def cli_installed?
|
9
|
+
ssh.run("which wp").success?
|
10
|
+
end
|
11
|
+
|
12
|
+
# Install wordpress CLI
|
13
|
+
# @return [Boolean]
|
14
|
+
def cli_install
|
15
|
+
log "Installing wordpress CLI"
|
16
|
+
|
17
|
+
ssh.run("sudo git clone --recursive --quiet #{CLI_GIT} #{CLI_PATH}")
|
18
|
+
ssh.run("cd #{CLI_PATH} && sudo utils/dev-build")
|
19
|
+
|
20
|
+
if cli_installed?
|
21
|
+
log "Wordpress CLI installed"
|
22
|
+
else
|
23
|
+
error "Unable to install wordpress CLI"
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
module Shuttle
|
2
|
+
module WordpressCore
|
3
|
+
# Get wordpress shared core path
|
4
|
+
# @return [String]
|
5
|
+
def core_path
|
6
|
+
@core_path ||= shared_path('wordpress/core')
|
7
|
+
end
|
8
|
+
|
9
|
+
# Check if wordpress core is installed
|
10
|
+
# @return [Boolean]
|
11
|
+
def core_installed?
|
12
|
+
ssh.directory_exists?(core_path) &&
|
13
|
+
!ssh.capture("ls #{core_path}").empty?
|
14
|
+
end
|
15
|
+
|
16
|
+
# Install wordpress shared core
|
17
|
+
# @param [Boolean] overwrite existing code
|
18
|
+
# @return [Boolean]
|
19
|
+
def core_install(overwrite=true)
|
20
|
+
if core_installed? && overwrite == true
|
21
|
+
core_remove
|
22
|
+
end
|
23
|
+
|
24
|
+
log "Installing wordpress core"
|
25
|
+
|
26
|
+
unless ssh.directory_exists?(core_path)
|
27
|
+
ssh.run("mkdir -p #{core_path}")
|
28
|
+
end
|
29
|
+
|
30
|
+
result = ssh.run("cd #{core_path} && wp core download")
|
31
|
+
|
32
|
+
if result.success?
|
33
|
+
log "Wordpress core installed"
|
34
|
+
else
|
35
|
+
error "Unable to install wordpress core: #{result.output}"
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
# Remove wordpress shared core
|
40
|
+
# @return [Boolean]
|
41
|
+
def core_remove
|
42
|
+
if ssh.directory_exists?(core_path)
|
43
|
+
log "Removing wordpress shared core"
|
44
|
+
ssh.run("rm -rf #{core_path}")
|
45
|
+
end
|
46
|
+
|
47
|
+
ssh.directory_exists?(core_path)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,112 @@
|
|
1
|
+
module Shuttle
|
2
|
+
module WordpressPlugins
|
3
|
+
# Install wordpresss plugin
|
4
|
+
# @param [String] plugin name slug
|
5
|
+
def plugin_install(name)
|
6
|
+
log "Installing plugin: #{name}"
|
7
|
+
|
8
|
+
res = ssh.run("cd #{release_path} && wp plugin install #{name}")
|
9
|
+
if !res.success?
|
10
|
+
error "Unable to install plugin '#{name}'. Reason: #{res.output}"
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
def plugin_custom_install(name, url)
|
15
|
+
log "Installing custom plugin: #{name} -> #{url}"
|
16
|
+
|
17
|
+
if git_url?(url)
|
18
|
+
install_git_plugin(name, url)
|
19
|
+
elsif file_url?(url)
|
20
|
+
install_file_plugin(name, url)
|
21
|
+
else
|
22
|
+
error "Valid git URL or archive URL is required for plugin: #{name}"
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
# Check if wordpress plugin is installed
|
27
|
+
# @return [Boolean]
|
28
|
+
def plugin_installed?(name)
|
29
|
+
raise "Not Implemented"
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
# Check if provided plugin url is a git repository
|
35
|
+
# @return [Boolean]
|
36
|
+
def git_url?(url)
|
37
|
+
url.include?('.git') || url.include?('git@') || url.include?('git://')
|
38
|
+
end
|
39
|
+
|
40
|
+
# Check if provided plugin url is a file
|
41
|
+
# @return [Boolean]
|
42
|
+
def file_url?(url)
|
43
|
+
name = File.basename(url)
|
44
|
+
name.include?('.zip') || name.include?('.tar.gz')
|
45
|
+
end
|
46
|
+
|
47
|
+
def install_git_plugin(plugin_name, url)
|
48
|
+
ssh.run "cd #{release_path}/wp-content/plugins"
|
49
|
+
res = ssh.run "git clone #{url} #{plugin_name}"
|
50
|
+
|
51
|
+
if res.failure?
|
52
|
+
error "Unable to install plugin '#{plugin_name}'. Reason: #{res.output}"
|
53
|
+
end
|
54
|
+
|
55
|
+
# Init submodules if any
|
56
|
+
if ssh.file_exists?("#{release_path}/wp-content/plugins/#{plugin_name}/.gitmodules")
|
57
|
+
log "Initializing git submodules for #{plugin_name}"
|
58
|
+
|
59
|
+
res = ssh.run("cd #{plugin_name} && git submodule update --init --recursive")
|
60
|
+
|
61
|
+
if res.failure?
|
62
|
+
error "Unable to update submodules for #{plugin_name}: #{res.output}"
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
# Cleanup git folder
|
67
|
+
ssh.run("rm -rf #{release_path}/wp-content/plugins/#{plugin_name}/.git")
|
68
|
+
end
|
69
|
+
|
70
|
+
def install_file_plugin(plugin_name, url)
|
71
|
+
name = File.basename(url)
|
72
|
+
plugin_path = "#{release_path}/wp-content/plugins/"
|
73
|
+
|
74
|
+
if ssh.file_exists?("/tmp/#{name}")
|
75
|
+
ssh.run("rm -f /tmp/#{name}")
|
76
|
+
end
|
77
|
+
|
78
|
+
# Download file first
|
79
|
+
log "Downloading #{url}"
|
80
|
+
result = ssh.run("cd /tmp && wget #{url}")
|
81
|
+
|
82
|
+
if result.failure?
|
83
|
+
error "Unable to download file from #{url}"
|
84
|
+
end
|
85
|
+
|
86
|
+
log "Extracting #{name} to plugins directory"
|
87
|
+
|
88
|
+
if name.include?('.zip')
|
89
|
+
check_unzip
|
90
|
+
|
91
|
+
if ssh.run("unzip /tmp/#{name} -d #{plugin_path}").failure?
|
92
|
+
error "Unable to extract plugin"
|
93
|
+
end
|
94
|
+
elsif name.include?('.tar.gz')
|
95
|
+
if ssh.run("tar -xzf #{name} -C #{plugin_path}").failure?
|
96
|
+
error "Unable to extract plugin"
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
def check_unzip
|
102
|
+
if ssh.run("which unzip").failure?
|
103
|
+
log "Unzip utility is missing. Installing..."
|
104
|
+
|
105
|
+
ssh.run("sudo apt-get update")
|
106
|
+
if ssh.run("sudo apt-get -y install unzip").failure?
|
107
|
+
error "Unable to install unzip utility"
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
@@ -0,0 +1,84 @@
|
|
1
|
+
module Shuttle
|
2
|
+
module WordpressVip
|
3
|
+
VIP_URL = "https://vip-svn.wordpress.com/plugins/"
|
4
|
+
|
5
|
+
# Get wordpress VIP shared path
|
6
|
+
# @return [String]
|
7
|
+
def vip_path
|
8
|
+
@vip_path ||= shared_path('wordpress/vip')
|
9
|
+
end
|
10
|
+
|
11
|
+
# Check if wordpress VIP is required
|
12
|
+
# @return [Boolean]
|
13
|
+
def vip_required?
|
14
|
+
!config.wordpress.vip.nil?
|
15
|
+
end
|
16
|
+
|
17
|
+
# Check if wordpress VIP is installed
|
18
|
+
# @return [Boolean]
|
19
|
+
def vip_installed?
|
20
|
+
ssh.directory_exists?(vip_path)
|
21
|
+
end
|
22
|
+
|
23
|
+
# Update wordpress VIP
|
24
|
+
def vip_update
|
25
|
+
if vip_installed?
|
26
|
+
ssh.run("rm -rf #{vip_path}")
|
27
|
+
end
|
28
|
+
|
29
|
+
vip_install
|
30
|
+
end
|
31
|
+
|
32
|
+
def vip_install
|
33
|
+
log "Installing wordpress VIP"
|
34
|
+
|
35
|
+
vip = vip_get_config
|
36
|
+
|
37
|
+
options = [
|
38
|
+
"--username #{vip.user}",
|
39
|
+
"--password #{vip.password}",
|
40
|
+
"--non-interactive",
|
41
|
+
VIP_URL,
|
42
|
+
vip_path
|
43
|
+
].join(' ')
|
44
|
+
|
45
|
+
cmd = "svn co #{options}"
|
46
|
+
|
47
|
+
res = ssh.run(cmd, &method(:stream_output))
|
48
|
+
|
49
|
+
if res.success?
|
50
|
+
log "Wordpress VIP installed"
|
51
|
+
else
|
52
|
+
raise DeployError, "Unable to install wordpress VIP. Reason: #{res.output}"
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def vip_get_config
|
57
|
+
data = config.wordpress.vip
|
58
|
+
if data.nil?
|
59
|
+
error "Please add VIP credentials to config."
|
60
|
+
end
|
61
|
+
|
62
|
+
if !data.user
|
63
|
+
error "VIP user is empty. Please set :user parameter"
|
64
|
+
end
|
65
|
+
|
66
|
+
if !data.password
|
67
|
+
error "VIP password is empty. Please set :password parameter"
|
68
|
+
end
|
69
|
+
|
70
|
+
data
|
71
|
+
end
|
72
|
+
|
73
|
+
def vip_link
|
74
|
+
ssh.run("mkdir -p #{release_path}/wp-content/themes/vip")
|
75
|
+
result = ssh.run("cp -a #{vip_path} #{release_path('wp-content/themes/vip/plugins')}")
|
76
|
+
|
77
|
+
if result.success?
|
78
|
+
log "Wordpress VIP is linked"
|
79
|
+
else
|
80
|
+
error "Unable to link VIP: #{result.output}"
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
@@ -0,0 +1,191 @@
|
|
1
|
+
require 'shuttle/deployment/wordpress/core'
|
2
|
+
require 'shuttle/deployment/wordpress/cli'
|
3
|
+
require 'shuttle/deployment/wordpress/vip'
|
4
|
+
require 'shuttle/deployment/wordpress/plugins'
|
5
|
+
|
6
|
+
module Shuttle
|
7
|
+
class Wordpress < Php
|
8
|
+
include WordpressCli
|
9
|
+
include WordpressCore
|
10
|
+
include WordpressVip
|
11
|
+
include WordpressPlugins
|
12
|
+
|
13
|
+
def setup
|
14
|
+
if config.wordpress.nil?
|
15
|
+
error "Please add :wordpress section to your config"
|
16
|
+
end
|
17
|
+
|
18
|
+
super
|
19
|
+
|
20
|
+
setup_shared_dirs
|
21
|
+
check_dependencies
|
22
|
+
cli_install if !cli_installed?
|
23
|
+
core_install if !core_installed?
|
24
|
+
check_config
|
25
|
+
end
|
26
|
+
|
27
|
+
def deploy
|
28
|
+
setup
|
29
|
+
update_code
|
30
|
+
link_shared_data
|
31
|
+
checkout_theme
|
32
|
+
|
33
|
+
if vip_required?
|
34
|
+
vip_install if !vip_installed?
|
35
|
+
vip_link
|
36
|
+
end
|
37
|
+
|
38
|
+
if !site_installed?
|
39
|
+
site_install
|
40
|
+
network_install
|
41
|
+
end
|
42
|
+
|
43
|
+
check_plugins
|
44
|
+
activate_theme
|
45
|
+
|
46
|
+
link_release
|
47
|
+
end
|
48
|
+
|
49
|
+
def check_dependencies
|
50
|
+
if !svn_installed?
|
51
|
+
log "Installing Subversion"
|
52
|
+
if ssh.run("sudo apt-get install -y subversion").success?
|
53
|
+
log "Subversion installed"
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def setup_shared_dirs
|
59
|
+
dirs = [
|
60
|
+
'wordpress',
|
61
|
+
'wordpress/uploads',
|
62
|
+
'wordpress/core',
|
63
|
+
'wordpress/plugins'
|
64
|
+
]
|
65
|
+
|
66
|
+
dirs.each do |path|
|
67
|
+
ssh.run("mkdir -p #{shared_path(path)}")
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def generate_config
|
72
|
+
mysql = config.wordpress.mysql
|
73
|
+
if mysql.nil?
|
74
|
+
error "Missing :mysql section of the config."
|
75
|
+
end
|
76
|
+
|
77
|
+
cmd = [
|
78
|
+
"wp core config",
|
79
|
+
"--dbname=#{mysql.database}",
|
80
|
+
"--dbhost=#{mysql.host || 'localhost'}",
|
81
|
+
"--dbuser=#{mysql.user}"
|
82
|
+
]
|
83
|
+
|
84
|
+
cmd << "--dbpass=#{mysql.password}" if mysql.password
|
85
|
+
|
86
|
+
res = ssh.run("cd #{core_path} && #{cmd.join(' ')}")
|
87
|
+
if res.success?
|
88
|
+
log "A new wordpress config has been generated"
|
89
|
+
else
|
90
|
+
error "Unable to generate config"
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
def check_config
|
95
|
+
if !ssh.file_exists?(shared_path('wp-config.php'))
|
96
|
+
log "Creating wordpress config at 'shared/wp-config.php'"
|
97
|
+
generate_config
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
def site_installed?
|
102
|
+
ssh.run("cd #{release_path} && wp").success?
|
103
|
+
end
|
104
|
+
|
105
|
+
def site_install
|
106
|
+
if config.wordpress.site
|
107
|
+
site = config.wordpress.site
|
108
|
+
|
109
|
+
cmd = [
|
110
|
+
"wp core install",
|
111
|
+
"--url=#{site.url}",
|
112
|
+
"--title=#{site.title}",
|
113
|
+
"--admin_name=#{site.admin_name}",
|
114
|
+
"--admin_email=#{site.admin_email}",
|
115
|
+
"--admin_password=#{site.admin_password}"
|
116
|
+
].join(' ')
|
117
|
+
|
118
|
+
result = ssh.run("cd #{release_path} && #{cmd}")
|
119
|
+
if result.failure?
|
120
|
+
error "Failed to setup site. #{result.output}"
|
121
|
+
end
|
122
|
+
else
|
123
|
+
error "Please define :site section"
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
def network_install
|
128
|
+
if config.wordpress.network
|
129
|
+
network = config.wordpress.network
|
130
|
+
|
131
|
+
cmd = [
|
132
|
+
"wp core install-network",
|
133
|
+
"--title=#{network.title}",
|
134
|
+
].join(' ')
|
135
|
+
|
136
|
+
result = ssh.run("cd #{release_path} && #{cmd}")
|
137
|
+
if result.failure?
|
138
|
+
error "Failed to setup WP network. #{result.output}"
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
def link_shared_data
|
144
|
+
log "Linking shared data"
|
145
|
+
|
146
|
+
ssh.run("cp -a #{core_path} #{release_path}")
|
147
|
+
ssh.run("cp #{shared_path('wp-config.php')} #{release_path('wp-config.php')}")
|
148
|
+
ssh.run("ln -s #{shared_path('wordpress/uploads')} #{release_path('wp-content/uploads')}")
|
149
|
+
end
|
150
|
+
|
151
|
+
def check_plugins
|
152
|
+
plugins = config.wordpress.plugins
|
153
|
+
|
154
|
+
if plugins
|
155
|
+
if plugins.kind_of?(Array)
|
156
|
+
plugins.each do |p|
|
157
|
+
if p.kind_of?(String)
|
158
|
+
plugin_install(p)
|
159
|
+
elsif p.kind_of?(Hash)
|
160
|
+
name, url = p.to_a.flatten.map(&:to_s)
|
161
|
+
plugin_custom_install(name, url)
|
162
|
+
end
|
163
|
+
end
|
164
|
+
else
|
165
|
+
error "Config file has invalid plugins section"
|
166
|
+
end
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
def checkout_theme
|
171
|
+
if config.wordpress
|
172
|
+
if config.wordpress.theme
|
173
|
+
checkout_code("wp-content/themes/#{config.wordpress.theme}")
|
174
|
+
else
|
175
|
+
error "Theme name is not defined."
|
176
|
+
end
|
177
|
+
else
|
178
|
+
error "Config does not contain 'wordpress' section"
|
179
|
+
end
|
180
|
+
end
|
181
|
+
|
182
|
+
def activate_theme
|
183
|
+
name = config.wordpress.theme
|
184
|
+
result = ssh.run("cd #{release_path} && wp theme activate #{name}")
|
185
|
+
|
186
|
+
if result.failure?
|
187
|
+
error "Unable to activate theme. Error: #{result.output}"
|
188
|
+
end
|
189
|
+
end
|
190
|
+
end
|
191
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
module Shuttle
|
2
|
+
module Helpers
|
3
|
+
LEVEL_COLORS = {
|
4
|
+
'info' => :green,
|
5
|
+
'warning' => :yellow,
|
6
|
+
'error' => :red
|
7
|
+
}
|
8
|
+
|
9
|
+
def log(message, level='info')
|
10
|
+
prefix = "----->".send(LEVEL_COLORS[level])
|
11
|
+
STDOUT.puts("#{prefix} #{message}")
|
12
|
+
end
|
13
|
+
|
14
|
+
def error(message)
|
15
|
+
log("ERROR: #{message}", 'error')
|
16
|
+
raise DeployError, message
|
17
|
+
end
|
18
|
+
|
19
|
+
def git_installed?
|
20
|
+
ssh.run("which git").success?
|
21
|
+
end
|
22
|
+
|
23
|
+
def svn_installed?
|
24
|
+
ssh.run("which svn").success?
|
25
|
+
end
|
26
|
+
|
27
|
+
def release_exists?
|
28
|
+
ssh.directory_exists?(release_path)
|
29
|
+
end
|
30
|
+
|
31
|
+
def stream_output(buff)
|
32
|
+
str = buff.split("\n").map { |str| " #{str}"}.join("\n")
|
33
|
+
STDOUT.puts(str)
|
34
|
+
end
|
35
|
+
|
36
|
+
def git_remote
|
37
|
+
result = ssh.run("cd #{scm_path} && git remote -v")
|
38
|
+
|
39
|
+
if result.success?
|
40
|
+
result.output.scan(/^origin\t(.+)\s\(fetch\)/).flatten.first
|
41
|
+
else
|
42
|
+
nil
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def deployer_hostname
|
47
|
+
`hostname`.strip
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|