slugforge 4.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/README.md +316 -0
- data/bin/slugforge +9 -0
- data/lib/slugforge.rb +19 -0
- data/lib/slugforge/build.rb +4 -0
- data/lib/slugforge/build/build_project.rb +31 -0
- data/lib/slugforge/build/export_upstart.rb +85 -0
- data/lib/slugforge/build/package.rb +63 -0
- data/lib/slugforge/cli.rb +125 -0
- data/lib/slugforge/commands.rb +130 -0
- data/lib/slugforge/commands/build.rb +20 -0
- data/lib/slugforge/commands/config.rb +24 -0
- data/lib/slugforge/commands/deploy.rb +383 -0
- data/lib/slugforge/commands/project.rb +21 -0
- data/lib/slugforge/commands/tag.rb +148 -0
- data/lib/slugforge/commands/wrangler.rb +142 -0
- data/lib/slugforge/configuration.rb +125 -0
- data/lib/slugforge/helper.rb +186 -0
- data/lib/slugforge/helper/build.rb +46 -0
- data/lib/slugforge/helper/config.rb +37 -0
- data/lib/slugforge/helper/enumerable.rb +46 -0
- data/lib/slugforge/helper/fog.rb +90 -0
- data/lib/slugforge/helper/git.rb +89 -0
- data/lib/slugforge/helper/path.rb +76 -0
- data/lib/slugforge/helper/project.rb +86 -0
- data/lib/slugforge/models/host.rb +233 -0
- data/lib/slugforge/models/host/fog_host.rb +33 -0
- data/lib/slugforge/models/host/hostname_host.rb +9 -0
- data/lib/slugforge/models/host/ip_address_host.rb +9 -0
- data/lib/slugforge/models/host_group.rb +65 -0
- data/lib/slugforge/models/host_group/aws_tag_group.rb +22 -0
- data/lib/slugforge/models/host_group/ec2_instance_group.rb +21 -0
- data/lib/slugforge/models/host_group/hostname_group.rb +16 -0
- data/lib/slugforge/models/host_group/ip_address_group.rb +16 -0
- data/lib/slugforge/models/host_group/security_group_group.rb +20 -0
- data/lib/slugforge/models/logger.rb +36 -0
- data/lib/slugforge/models/tag_manager.rb +125 -0
- data/lib/slugforge/slugins.rb +125 -0
- data/lib/slugforge/version.rb +9 -0
- data/scripts/post-install.sh +143 -0
- data/scripts/unicorn-shepherd.sh +305 -0
- data/spec/fixtures/array.yaml +3 -0
- data/spec/fixtures/fog_credentials.yaml +4 -0
- data/spec/fixtures/invalid_syntax.yaml +1 -0
- data/spec/fixtures/one.yaml +3 -0
- data/spec/fixtures/two.yaml +3 -0
- data/spec/fixtures/valid.yaml +4 -0
- data/spec/slugforge/commands/deploy_spec.rb +72 -0
- data/spec/slugforge/commands_spec.rb +33 -0
- data/spec/slugforge/configuration_spec.rb +200 -0
- data/spec/slugforge/helper/fog_spec.rb +81 -0
- data/spec/slugforge/helper/git_spec.rb +152 -0
- data/spec/slugforge/models/host_group/aws_tag_group_spec.rb +54 -0
- data/spec/slugforge/models/host_group/ec2_instance_group_spec.rb +51 -0
- data/spec/slugforge/models/host_group/hostname_group_spec.rb +20 -0
- data/spec/slugforge/models/host_group/ip_address_group_spec.rb +54 -0
- data/spec/slugforge/models/host_group/security_group_group_spec.rb +52 -0
- data/spec/slugforge/models/tag_manager_spec.rb +75 -0
- data/spec/spec_helper.rb +37 -0
- data/spec/support/env.rb +3 -0
- data/spec/support/example_groups/configuration_writer.rb +24 -0
- data/spec/support/example_groups/helper_provider.rb +10 -0
- data/spec/support/factories.rb +18 -0
- data/spec/support/fog.rb +15 -0
- data/spec/support/helpers.rb +18 -0
- data/spec/support/mock_logger.rb +6 -0
- data/spec/support/ssh.rb +8 -0
- data/spec/support/streams.rb +13 -0
- data/templates/foreman/master.conf.erb +21 -0
- data/templates/foreman/process-master.conf.erb +2 -0
- data/templates/foreman/process.conf.erb +19 -0
- metadata +344 -0
@@ -0,0 +1,233 @@
|
|
1
|
+
require 'net/ssh'
|
2
|
+
require 'net/scp'
|
3
|
+
|
4
|
+
module Slugforge
|
5
|
+
class Host
|
6
|
+
attr_reader :pattern, :server, :slug_name, :status
|
7
|
+
|
8
|
+
def initialize(pattern, server=nil)
|
9
|
+
@pattern = pattern
|
10
|
+
@server = server
|
11
|
+
@deploy_results = []
|
12
|
+
@timeline = []
|
13
|
+
@start_time = Time.now
|
14
|
+
@actions = []
|
15
|
+
self
|
16
|
+
end
|
17
|
+
|
18
|
+
def name
|
19
|
+
"name:#{@pattern}"
|
20
|
+
end
|
21
|
+
|
22
|
+
def ip
|
23
|
+
@pattern
|
24
|
+
end
|
25
|
+
|
26
|
+
def ssh_host
|
27
|
+
@pattern
|
28
|
+
end
|
29
|
+
|
30
|
+
def id
|
31
|
+
nil
|
32
|
+
end
|
33
|
+
|
34
|
+
def is_autoscaled?
|
35
|
+
false
|
36
|
+
end
|
37
|
+
|
38
|
+
def to_status
|
39
|
+
{
|
40
|
+
:name => name,
|
41
|
+
:ip => ip,
|
42
|
+
:pattern => @pattern,
|
43
|
+
:slug_name => @slug_name,
|
44
|
+
:status => @status.to_s,
|
45
|
+
:output => @deploy_results,
|
46
|
+
:start_time => @start_time,
|
47
|
+
:timeline => timeline,
|
48
|
+
}
|
49
|
+
end
|
50
|
+
|
51
|
+
def elapsed_time
|
52
|
+
Time.at(Time.now - @start_time).strftime('%M:%S')
|
53
|
+
end
|
54
|
+
|
55
|
+
def record_event(status)
|
56
|
+
@status = status
|
57
|
+
@timeline << {:status => status, :elapsed_time => elapsed_time}
|
58
|
+
end
|
59
|
+
|
60
|
+
def timeline
|
61
|
+
@timeline.map { |event| "#{event[:status]} @ #{event[:elapsed_time]}" }.join ', '
|
62
|
+
end
|
63
|
+
|
64
|
+
def complete?
|
65
|
+
success? || failed?
|
66
|
+
end
|
67
|
+
|
68
|
+
def success?
|
69
|
+
[:deployed, :terminated].include?(@status) && output.empty?
|
70
|
+
end
|
71
|
+
|
72
|
+
def failed?
|
73
|
+
@status == :failed?
|
74
|
+
end
|
75
|
+
|
76
|
+
def add_action(action)
|
77
|
+
@actions << action
|
78
|
+
end
|
79
|
+
|
80
|
+
def has_action?(action)
|
81
|
+
@actions.include?(action)
|
82
|
+
end
|
83
|
+
|
84
|
+
def remove_action(action)
|
85
|
+
@actions.delete(action)
|
86
|
+
end
|
87
|
+
|
88
|
+
def stage?
|
89
|
+
@actions.include?(:stage)
|
90
|
+
end
|
91
|
+
|
92
|
+
def install?
|
93
|
+
@actions.include?(:install)
|
94
|
+
end
|
95
|
+
|
96
|
+
def effective_action
|
97
|
+
return "installing" if @actions.include?(:install)
|
98
|
+
"staging"
|
99
|
+
end
|
100
|
+
|
101
|
+
def terminated?
|
102
|
+
@status == :terminated?
|
103
|
+
end
|
104
|
+
|
105
|
+
def output
|
106
|
+
@deploy_results.map { |result| result[:output] unless result[:exit_code] == 0 }.compact
|
107
|
+
end
|
108
|
+
|
109
|
+
def deploy(slug_name, logger, opts)
|
110
|
+
begin
|
111
|
+
record_event :started
|
112
|
+
if opts[:pretend]
|
113
|
+
logger.log "not actually #{effective_action} slug (#{name})", {:color => :yellow, :status => :pretend}
|
114
|
+
else
|
115
|
+
logger.say_status :deploy, "#{effective_action} to host #{name} as #{username(opts)}", :green
|
116
|
+
Net::SSH.start(ssh_host, username(opts), ssh_opts(opts)) do |ssh|
|
117
|
+
host_slug = detect_slug(ssh, slug_name, logger) unless opts[:force]
|
118
|
+
host_slug ||= copy_slug(ssh, slug_name, logger, opts)
|
119
|
+
explode_slug(ssh, host_slug, logger, opts) if stage?
|
120
|
+
install_slug(ssh, host_slug, logger, opts) if install?
|
121
|
+
end
|
122
|
+
end
|
123
|
+
record_event :deployed
|
124
|
+
rescue => e
|
125
|
+
record_event :failed
|
126
|
+
message = "#{e.class.to_s}: #{e.to_s}"
|
127
|
+
logger.log "#{message} (#{ip}: #{name})", {:color => :red, :status => :fail}
|
128
|
+
@deploy_results << {:output => message}
|
129
|
+
end
|
130
|
+
logger.say_status :deploy, "#{effective_action} complete for host: #{name}", success? ? :green : :red
|
131
|
+
@deploy_results
|
132
|
+
end
|
133
|
+
|
134
|
+
private
|
135
|
+
def ssh_opts(opts = {})
|
136
|
+
ssh_opts = { :forward_agent => true }
|
137
|
+
if opts[:identity]
|
138
|
+
ssh_opts[:key_data] = File.read(opts[:identity])
|
139
|
+
ssh_opts[:keys_only] = true
|
140
|
+
end
|
141
|
+
ssh_opts
|
142
|
+
end
|
143
|
+
|
144
|
+
def detect_slug(ssh, slug_name, logger)
|
145
|
+
found_path = ['/tmp', '/mnt'].select do |path|
|
146
|
+
file_count(ssh, path, slug_name) > 0
|
147
|
+
end.compact
|
148
|
+
return nil if found_path.empty?
|
149
|
+
slug_name_with_path = "#{found_path.first}/#{slug_name}"
|
150
|
+
logger.log "found existing slug (#{slug_name_with_path}) on host (#{name}); use --force to overwrite slug", {:color => :yellow, :status => :detect, :log_level => :vervose}
|
151
|
+
record_event :detected
|
152
|
+
slug_name_with_path
|
153
|
+
end
|
154
|
+
|
155
|
+
def copy_slug(ssh, slug_name, logger, opts)
|
156
|
+
slug_name_with_path = "/mnt/#{slug_name}"
|
157
|
+
case opts[:copy_type]
|
158
|
+
when :ssh
|
159
|
+
logger.log "interactive mode enabled (be sure to set slug_name)", {:color => :yellow, :status => :copy, :log_level => :verbose}
|
160
|
+
binding.pry
|
161
|
+
when :scp
|
162
|
+
logger.log "copying slug to host via SCP (#{name})", {:color => :green, :status => :copy, :log_level => :verbose}
|
163
|
+
scp_upload ip, username(opts), opts[:filename], "#{slug_name}", logger, :ssh => ssh_opts
|
164
|
+
logger.log "moving slug from ~ to /mnt as root", {:color => :green, :status => :copy, :log_level => :verbose}
|
165
|
+
@deploy_results << ssh_command(ssh, "sudo mv #{slug_name} #{slug_name_with_path}", logger)
|
166
|
+
else # AWS S3 command line by default
|
167
|
+
logger.log "copying slug to host via aws s3 command (#{name})", {:color => :green, :status => :copy, :log_level => :verbose}
|
168
|
+
@deploy_results << ssh_command(ssh, "sudo -- sh -c 'export AWS_ACCESS_KEY_ID=#{opts[:aws_session][:aws_access_key_id]}; export AWS_SECRET_ACCESS_KEY=#{opts[:aws_session][:aws_secret_access_key]}; export AWS_SECURITY_TOKEN=#{opts[:aws_session][:aws_session_token]}; export AWS_DEFAULT_REGION=#{opts[:aws_session][:aws_region]}; aws s3 cp #{opts[:s3_url]} #{slug_name_with_path}'; echo \"SSH_COMMAND_EXIT_CODE=$?\"", logger)
|
169
|
+
end
|
170
|
+
record_event :copied
|
171
|
+
slug_name_with_path
|
172
|
+
end
|
173
|
+
|
174
|
+
def username(opts)
|
175
|
+
opts[:username] || Net::SSH.configuration_for(ip)[:user] || `whoami`.chomp
|
176
|
+
end
|
177
|
+
|
178
|
+
def explode_slug(ssh, slug_name_with_path, logger, opts)
|
179
|
+
logger.log "exploding package as root #{"for user " + opts[:owner] if opts[:owner]}", {:color => :green, :status => :stage, :log_level => :verbose}
|
180
|
+
@deploy_results << ssh_command(ssh, slug_install_command(slug_name_with_path, opts[:deploy_dir], {:unpack => true, :owner => opts[:owner], :env => opts[:env]}), logger)
|
181
|
+
@slug_name = slug_name
|
182
|
+
end
|
183
|
+
|
184
|
+
def install_slug(ssh, slug_name_with_path, logger, opts)
|
185
|
+
logger.log "installing package as root #{"for user " + opts[:owner] if opts[:owner]}", {:color => :green, :status => :install, :log_level => :verbose}
|
186
|
+
@deploy_results << ssh_command(ssh, slug_install_command(slug_name_with_path, opts[:deploy_dir], {:owner => opts[:owner], :env => opts[:env], :force => opts[:force]}), logger)
|
187
|
+
@slug_name = slug_name
|
188
|
+
record_event :installed
|
189
|
+
end
|
190
|
+
|
191
|
+
def file_count(ssh, path, file)
|
192
|
+
ssh.exec!("find #{path} -maxdepth 1 -name '#{file}' -type f -size +0 | wc -l").to_i
|
193
|
+
end
|
194
|
+
|
195
|
+
def scp_upload(host, user, source, dest, logger, opts)
|
196
|
+
logger.log "SCP: #{source} to #{host}:#{dest}"
|
197
|
+
Net::SCP.upload!(host, user, source, dest, opts) do | ch, name, sent, total |
|
198
|
+
logger.log "\r#{name}: #{(sent * 100.0 / total).to_i}% "
|
199
|
+
end
|
200
|
+
logger.log
|
201
|
+
end
|
202
|
+
|
203
|
+
def ssh_command(ssh, command, logger)
|
204
|
+
output = ssh.exec!("#{command}")
|
205
|
+
exit_code_matches = /^SSH_COMMAND_EXIT_CODE=(\d+)$/.match(output)
|
206
|
+
exit_code = exit_code_matches ? exit_code_matches[1].to_i : 0
|
207
|
+
logger_opts = if exit_code == 0
|
208
|
+
{:color => :green, :log_level => :verbose}
|
209
|
+
else
|
210
|
+
{:color => :red}
|
211
|
+
end.merge({:status => :command})
|
212
|
+
logger.log "#{command}", logger_opts
|
213
|
+
logger.log "Output:\n#{output}", logger_opts
|
214
|
+
{:exit_code => exit_code, :output => output, :command => command, :username => ssh.options[:user]}
|
215
|
+
end
|
216
|
+
|
217
|
+
def slug_install_command(slug_name_with_path, deploy_dir, opts = {})
|
218
|
+
[ opts[:prefix],
|
219
|
+
"TERM=dumb sudo bash -l -c 'date >> /var/log/slug_deploy.log ; ",
|
220
|
+
"chmod +x #{slug_name_with_path} ",
|
221
|
+
"&& #{opts[:env]} #{slug_name_with_path} ",
|
222
|
+
'-y ', #always clobber existing installs
|
223
|
+
"-i #{deploy_dir} ",
|
224
|
+
opts[:owner] ? "-o #{opts[:owner]} " : '',
|
225
|
+
opts[:force] ? '-f ' : '',
|
226
|
+
opts[:unpack] ? '-u ' : '',
|
227
|
+
'-v ', #verbose
|
228
|
+
'| tee -a /var/log/slug_deploy.log ',
|
229
|
+
"; echo \"SSH_COMMAND_EXIT_CODE=$?\"'"
|
230
|
+
].join('')
|
231
|
+
end
|
232
|
+
end
|
233
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
require 'slugforge/models/host'
|
2
|
+
|
3
|
+
module Slugforge
|
4
|
+
class FogHost < Host
|
5
|
+
def name
|
6
|
+
"instance:#{@server.id}, private_name:#{@server.private_dns_name}, public_name:#{@server.dns_name}, ip:#{@server.public_ip_address}"
|
7
|
+
end
|
8
|
+
|
9
|
+
def ip
|
10
|
+
@server.public_ip_address
|
11
|
+
end
|
12
|
+
|
13
|
+
def ssh_host
|
14
|
+
@server.dns_name
|
15
|
+
end
|
16
|
+
|
17
|
+
def id
|
18
|
+
@server.id
|
19
|
+
end
|
20
|
+
|
21
|
+
def is_autoscaled?
|
22
|
+
!@server.tags["aws:autoscaling:groupName"].nil?
|
23
|
+
end
|
24
|
+
|
25
|
+
def to_status
|
26
|
+
super.merge({
|
27
|
+
:instance_id => @server.id,
|
28
|
+
:private_name => @server.private_dns_name,
|
29
|
+
:public_name => @server.dns_name,
|
30
|
+
})
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
require 'slugforge/models/host_group/ip_address_group'
|
2
|
+
require 'slugforge/models/host_group/ec2_instance_group'
|
3
|
+
require 'slugforge/models/host_group/hostname_group'
|
4
|
+
require 'slugforge/models/host_group/aws_tag_group'
|
5
|
+
require 'slugforge/models/host_group/security_group_group'
|
6
|
+
|
7
|
+
module Slugforge
|
8
|
+
class HostGroup
|
9
|
+
attr_reader :name, :hosts
|
10
|
+
|
11
|
+
def self.discover(patterns, compute)
|
12
|
+
patterns.map do |pattern|
|
13
|
+
IpAddressGroup.detect(pattern, compute) ||
|
14
|
+
Ec2InstanceGroup.detect(pattern, compute) ||
|
15
|
+
HostnameGroup.detect(pattern, compute) ||
|
16
|
+
AwsTagGroup.detect(pattern, compute) ||
|
17
|
+
SecurityGroupGroup.detect(pattern, compute) ||
|
18
|
+
# If nothing detected, return a "null" group
|
19
|
+
HostGroup.new(pattern, compute)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def initialize(pattern, compute)
|
24
|
+
@name = pattern
|
25
|
+
end
|
26
|
+
|
27
|
+
def install_all
|
28
|
+
return if @hosts.nil?
|
29
|
+
@hosts.each { |host| host.add_action(:install) }
|
30
|
+
end
|
31
|
+
|
32
|
+
def install_percent_of_hosts(value)
|
33
|
+
return if @hosts.nil?
|
34
|
+
count = (@hosts.count * value / 100.0).ceil
|
35
|
+
sorted_hosts[0...count].each { |host| host.add_action(:install) }
|
36
|
+
end
|
37
|
+
|
38
|
+
def install_number_of_hosts(value)
|
39
|
+
return if @hosts.nil?
|
40
|
+
count = [@hosts.count, value].min
|
41
|
+
sorted_hosts[0...count].each { |host| host.add_action(:install) }
|
42
|
+
end
|
43
|
+
|
44
|
+
def sorted_hosts
|
45
|
+
# We sort the hosts by IP to make the order deterministic before we filter
|
46
|
+
# by number or percent. That way when we move from 5% to 10% we end up at
|
47
|
+
# 10% of the hosts, not some value between 10% and 15%.
|
48
|
+
@hosts.sort_by { |host| host.ip }
|
49
|
+
end
|
50
|
+
|
51
|
+
def success?
|
52
|
+
@hosts.all?(&:success?)
|
53
|
+
end
|
54
|
+
|
55
|
+
def hosts_for_action(action)
|
56
|
+
@hosts.select { |host| host.has_action?(action) }
|
57
|
+
end
|
58
|
+
|
59
|
+
def self.detect(pattern, compute)
|
60
|
+
return nil unless pattern =~ self.matcher
|
61
|
+
group = self.new(pattern, compute)
|
62
|
+
group.hosts.empty? ? nil : group
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
require 'slugforge/models/host/fog_host'
|
2
|
+
|
3
|
+
module Slugforge
|
4
|
+
class HostGroup ; end
|
5
|
+
|
6
|
+
class AwsTagGroup < HostGroup
|
7
|
+
def self.matcher
|
8
|
+
/^(\w+)=(\w+)$/
|
9
|
+
end
|
10
|
+
|
11
|
+
def initialize(pattern, compute)
|
12
|
+
matches = self.class.matcher.match(pattern)
|
13
|
+
return nil unless matches
|
14
|
+
@hosts = compute.servers.select do |server|
|
15
|
+
server.tags[matches[1]] == matches[2] && !server.public_ip_address.nil?
|
16
|
+
end.map do |server|
|
17
|
+
FogHost.new(pattern, server)
|
18
|
+
end
|
19
|
+
super
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
require 'slugforge/models/host/fog_host'
|
2
|
+
|
3
|
+
module Slugforge
|
4
|
+
class HostGroup ; end
|
5
|
+
|
6
|
+
class Ec2InstanceGroup < HostGroup
|
7
|
+
def self.matcher
|
8
|
+
/^i-[0-9a-f]{8}$/i
|
9
|
+
end
|
10
|
+
|
11
|
+
def initialize(pattern, compute)
|
12
|
+
server = compute.servers.get(pattern)
|
13
|
+
@hosts = if server.nil? || server.public_ip_address.nil?
|
14
|
+
[]
|
15
|
+
else
|
16
|
+
[ FogHost.new(pattern, server) ]
|
17
|
+
end
|
18
|
+
super
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
require 'slugforge/models/host/hostname_host'
|
2
|
+
|
3
|
+
module Slugforge
|
4
|
+
class HostGroup ; end
|
5
|
+
|
6
|
+
class HostnameGroup < HostGroup
|
7
|
+
def self.matcher
|
8
|
+
/^[^.]+\./
|
9
|
+
end
|
10
|
+
|
11
|
+
def initialize(pattern, compute)
|
12
|
+
@hosts = [ HostnameHost.new(pattern) ]
|
13
|
+
super
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
require 'slugforge/models/host/ip_address_host'
|
2
|
+
|
3
|
+
module Slugforge
|
4
|
+
class HostGroup ; end
|
5
|
+
|
6
|
+
class IpAddressGroup < HostGroup
|
7
|
+
def self.matcher
|
8
|
+
/^(\d{1,3}\.){3}(\d{1,3})$/
|
9
|
+
end
|
10
|
+
|
11
|
+
def initialize(pattern, compute)
|
12
|
+
@hosts = [ IpAddressHost.new(pattern) ]
|
13
|
+
super
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
require 'slugforge/models/host/fog_host'
|
2
|
+
|
3
|
+
module Slugforge
|
4
|
+
class HostGroup ; end
|
5
|
+
|
6
|
+
class SecurityGroupGroup < HostGroup
|
7
|
+
def self.matcher
|
8
|
+
/\w+/
|
9
|
+
end
|
10
|
+
|
11
|
+
def initialize(pattern, compute)
|
12
|
+
@hosts = compute.servers.select do |server|
|
13
|
+
server.groups.include?(pattern) && !server.public_ip_address.nil?
|
14
|
+
end.map do |server|
|
15
|
+
FogHost.new(pattern, server)
|
16
|
+
end
|
17
|
+
super
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|