mortar 0.15.26 → 0.15.27
Sign up to get free protection for your applications and to get access to all the features.
- data/lib/mortar/auth.rb +13 -5
- data/lib/mortar/command/base.rb +20 -0
- data/lib/mortar/command/help.rb +1 -1
- data/lib/mortar/command/jobs.rb +2 -2
- data/lib/mortar/command/local.rb +21 -8
- data/lib/mortar/command/luigi.rb +86 -0
- data/lib/mortar/generators/project_generator.rb +2 -0
- data/lib/mortar/git.rb +30 -0
- data/lib/mortar/local/controller.rb +6 -2
- data/lib/mortar/local/installutil.rb +9 -1
- data/lib/mortar/local/params.rb +68 -0
- data/lib/mortar/local/pig.rb +8 -29
- data/lib/mortar/local/python.rb +21 -2
- data/lib/mortar/templates/project/gitignore +1 -0
- data/lib/mortar/templates/project/luigiscripts/client.cfg.template +33 -0
- data/lib/mortar/templates/project/luigiscripts/luigiscript.py +134 -0
- data/lib/mortar/templates/project/project.manifest +1 -0
- data/lib/mortar/templates/script/runstillson.sh +25 -0
- data/lib/mortar/version.rb +1 -1
- data/spec/mortar/auth_spec.rb +8 -0
- data/spec/mortar/command/generate_spec.rb +1 -0
- data/spec/mortar/command/jobs_spec.rb +13 -13
- data/spec/mortar/command/local_spec.rb +41 -3
- data/spec/mortar/command/luigi_spec.rb +117 -0
- data/spec/mortar/command/projects_spec.rb +5 -0
- data/spec/mortar/git_spec.rb +105 -0
- data/spec/mortar/local/installutil_spec.rb +6 -0
- data/spec/mortar/local/params_spec.rb +101 -0
- data/spec/mortar/plugin_spec.rb +1 -1
- data/spec/spec_helper.rb +2 -0
- metadata +14 -7
data/lib/mortar/auth.rb
CHANGED
@@ -72,16 +72,24 @@ class Mortar::Auth
|
|
72
72
|
@credentials = ask_for_and_save_credentials
|
73
73
|
end
|
74
74
|
|
75
|
-
def user # :nodoc:
|
76
|
-
|
75
|
+
def user(local=false) # :nodoc:
|
76
|
+
if (local && !has_credentials)
|
77
|
+
"notloggedin@user.org"
|
78
|
+
else
|
79
|
+
get_credentials[0]
|
80
|
+
end
|
77
81
|
end
|
78
82
|
|
79
|
-
def password # :nodoc:
|
80
|
-
|
83
|
+
def password(local=false) # :nodoc:
|
84
|
+
if (local && !has_credentials)
|
85
|
+
"notloggedin"
|
86
|
+
else
|
87
|
+
get_credentials[1]
|
88
|
+
end
|
81
89
|
end
|
82
90
|
|
83
91
|
def user_s3_safe(local = false)
|
84
|
-
user_email = (local
|
92
|
+
user_email = user(local)
|
85
93
|
return user_email.gsub(/[^0-9a-zA-Z]/i, '-')
|
86
94
|
end
|
87
95
|
|
data/lib/mortar/command/base.rb
CHANGED
@@ -104,6 +104,26 @@ class Mortar::Command::Base
|
|
104
104
|
param_list
|
105
105
|
end
|
106
106
|
|
107
|
+
def luigi_parameters
|
108
|
+
parameters = []
|
109
|
+
invalid_arguments.each_slice(2) do |arg_pair|
|
110
|
+
name_with_dashes = arg_pair[0]
|
111
|
+
unless (name_with_dashes.start_with? "--") && (name_with_dashes.length > 2)
|
112
|
+
error("Luigi parameter #{name_with_dashes} must begin with --")
|
113
|
+
end
|
114
|
+
|
115
|
+
unless arg_pair.length == 2
|
116
|
+
error("No value provided for luigi parameter #{name_with_dashes}")
|
117
|
+
end
|
118
|
+
|
119
|
+
name = name_with_dashes[2..name_with_dashes.length-1]
|
120
|
+
value = arg_pair[1]
|
121
|
+
parameters << {"name" => name, "value" => value}
|
122
|
+
end
|
123
|
+
|
124
|
+
parameters
|
125
|
+
end
|
126
|
+
|
107
127
|
def pig_parameters
|
108
128
|
paramfile_params = {}
|
109
129
|
if options[:param_file]
|
data/lib/mortar/command/help.rb
CHANGED
@@ -23,7 +23,7 @@ require "mortar/command/base"
|
|
23
23
|
#
|
24
24
|
class Mortar::Command::Help < Mortar::Command::Base
|
25
25
|
|
26
|
-
PRIMARY_NAMESPACES = %w( auth clusters generate
|
26
|
+
PRIMARY_NAMESPACES = %w( auth config clusters generate jobs local luigi projects )
|
27
27
|
|
28
28
|
# help [COMMAND]
|
29
29
|
#
|
data/lib/mortar/command/jobs.rb
CHANGED
@@ -164,7 +164,7 @@ class Mortar::Command::Jobs < Mortar::Command::Base
|
|
164
164
|
cluster_type = CLUSTER_TYPE__PERMANENT
|
165
165
|
end
|
166
166
|
use_spot_instances = options[:spot] || false
|
167
|
-
api.
|
167
|
+
api.post_pig_job_new_cluster(project_name, script_name, git_ref, cluster_size,
|
168
168
|
:pig_version => pig_version.version,
|
169
169
|
:project_script_path => script.rel_path,
|
170
170
|
:parameters => pig_parameters,
|
@@ -174,7 +174,7 @@ class Mortar::Command::Jobs < Mortar::Command::Base
|
|
174
174
|
:use_spot_instances => use_spot_instances).body
|
175
175
|
else
|
176
176
|
cluster_id = options[:clusterid]
|
177
|
-
api.
|
177
|
+
api.post_pig_job_existing_cluster(project_name, script_name, git_ref, cluster_id,
|
178
178
|
:pig_version => pig_version.version,
|
179
179
|
:project_script_path => script.rel_path,
|
180
180
|
:parameters => pig_parameters,
|
data/lib/mortar/command/local.rb
CHANGED
@@ -225,12 +225,12 @@ class Mortar::Command::Local < Mortar::Command::Base
|
|
225
225
|
|
226
226
|
# local:luigi SCRIPT
|
227
227
|
#
|
228
|
-
# Run a luigi
|
228
|
+
# Run a luigi pipeline script on your local machine in local scheduler mode.
|
229
229
|
# Any additional command line arguments will be passed directly to the luigi script.
|
230
230
|
#
|
231
|
-
# -p, --parameter NAME=VALUE # Set a pig parameter value in your script.
|
232
|
-
# -f, --param-file PARAMFILE # Load pig parameter values from a file.
|
233
231
|
# --project-root PROJECTDIR # The root directory of the project if not the CWD
|
232
|
+
# -p, --parameter NAME=VALUE # [deprecated] Instead, pass luigi parameters directly as options (see below)
|
233
|
+
# -f, --param-file PARAMFILE # [deprecated] Instead, pass luigi parameters directly as options (see below)
|
234
234
|
#
|
235
235
|
#Examples:
|
236
236
|
#
|
@@ -241,8 +241,7 @@ class Mortar::Command::Local < Mortar::Command::Base
|
|
241
241
|
unless script_name
|
242
242
|
error("Usage: mortar local:luigi SCRIPT\nMust specify SCRIPT.")
|
243
243
|
end
|
244
|
-
|
245
|
-
|
244
|
+
|
246
245
|
# cd into the project root
|
247
246
|
project_root = options[:project_root] ||= Dir.getwd
|
248
247
|
unless File.directory?(project_root)
|
@@ -257,10 +256,24 @@ class Mortar::Command::Local < Mortar::Command::Base
|
|
257
256
|
git_ref = sync_code_with_cloud()
|
258
257
|
ENV['MORTAR_LUIGI_GIT_REF'] = git_ref
|
259
258
|
|
259
|
+
# pick up standard luigi-style params provided by the user
|
260
|
+
luigi_cli_parameters = luigi_parameters()
|
261
|
+
|
262
|
+
# pick up old pig-style parameters (included for backwards compatibility)
|
263
|
+
pig_style_parameters = pig_parameters()
|
264
|
+
if pig_style_parameters.length > 0
|
265
|
+
warn "[DEPRECATION] Passing luigi parameters with -p is deprecated. Please pass them directly (e.g. --myparam myvalue)"
|
266
|
+
end
|
267
|
+
|
268
|
+
luigi_cli_parameters.concat(pig_style_parameters)
|
269
|
+
cli_parameters = \
|
270
|
+
luigi_cli_parameters.sort_by { |p| p['name'] }.map { |arg| ["--#{arg['name']}", "#{arg['value']}"] }.flatten
|
271
|
+
|
272
|
+
# get project configuration parameters
|
273
|
+
project_config_params = config_parameters()
|
274
|
+
|
260
275
|
ctrl = Mortar::Local::Controller.new
|
261
|
-
|
262
|
-
luigi_params = luigi_params.map { |arg| ["--#{arg['name']}", "#{arg['value']}"] }.flatten
|
263
|
-
ctrl.run_luigi(pig_version, script, luigi_params)
|
276
|
+
ctrl.run_luigi(pig_version, script, cli_parameters, project_config_params)
|
264
277
|
end
|
265
278
|
|
266
279
|
# local:sqoop_table dbtype database-name table s3-destination
|
@@ -0,0 +1,86 @@
|
|
1
|
+
#
|
2
|
+
# Copyright 2014 Mortar Data Inc.
|
3
|
+
#
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
5
|
+
# you may not use this file except in compliance with the License.
|
6
|
+
# You may obtain a copy of the License at
|
7
|
+
#
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
9
|
+
#
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13
|
+
# See the License for the specific language governing permissions and
|
14
|
+
# limitations under the License.
|
15
|
+
#
|
16
|
+
|
17
|
+
require "mortar/command/base"
|
18
|
+
require "time"
|
19
|
+
|
20
|
+
# run luigi pipeline jobs
|
21
|
+
#
|
22
|
+
class Mortar::Command::Luigi < Mortar::Command::Base
|
23
|
+
|
24
|
+
include Mortar::Git
|
25
|
+
|
26
|
+
# luigi SCRIPT
|
27
|
+
#
|
28
|
+
# Run a luigi pipeline.
|
29
|
+
#
|
30
|
+
# -P, --project PROJECTNAME # Use a project that is not checked out in the current directory. Runs code from project's master branch in GitHub rather than snapshotting local code.
|
31
|
+
# -B, --branch BRANCHNAME # Used with --project to specify a non-master branch
|
32
|
+
#
|
33
|
+
# Examples:
|
34
|
+
#
|
35
|
+
# Run the nightly_rollup luigiscript:
|
36
|
+
# $ mortar luigi luigiscripts/nightly_rollup.py
|
37
|
+
#
|
38
|
+
# Run the nightly_rollup luigiscript with two parameters:
|
39
|
+
# $ mortar luigi luigiscripts/nightly_rollup.py --data-date 2012-02-01 --my-param myval
|
40
|
+
#
|
41
|
+
def index
|
42
|
+
script_name = shift_argument
|
43
|
+
unless script_name
|
44
|
+
error("Usage: mortar luigi SCRIPT\nMust specify SCRIPT.")
|
45
|
+
end
|
46
|
+
|
47
|
+
if options[:project]
|
48
|
+
project_name = options[:project]
|
49
|
+
if File.extname(script_name) == ".py"
|
50
|
+
script_name = File.basename(script_name, ".*")
|
51
|
+
end
|
52
|
+
else
|
53
|
+
project_name = project.name
|
54
|
+
script = validate_luigiscript!(script_name)
|
55
|
+
script_name = script.name
|
56
|
+
end
|
57
|
+
|
58
|
+
parameters = luigi_parameters()
|
59
|
+
|
60
|
+
if options[:project]
|
61
|
+
if options[:branch]
|
62
|
+
git_ref = options[:branch]
|
63
|
+
else
|
64
|
+
git_ref = "master"
|
65
|
+
end
|
66
|
+
else
|
67
|
+
git_ref = sync_code_with_cloud()
|
68
|
+
end
|
69
|
+
|
70
|
+
# post job to API
|
71
|
+
response = action("Requesting job execution") do
|
72
|
+
api.post_luigi_job(project_name, script_name, git_ref,
|
73
|
+
:project_script_path => script.rel_path,
|
74
|
+
:parameters => parameters).body
|
75
|
+
end
|
76
|
+
|
77
|
+
display("job_id: #{response['job_id']}")
|
78
|
+
display
|
79
|
+
display("Job status can be viewed on the web at:\n\n #{response['web_job_url']}")
|
80
|
+
display
|
81
|
+
|
82
|
+
response['job_id']
|
83
|
+
end
|
84
|
+
|
85
|
+
alias_command "luigi:run", "luigi"
|
86
|
+
end
|
data/lib/mortar/git.rb
CHANGED
@@ -160,6 +160,19 @@ module Mortar
|
|
160
160
|
manifest_pathlist
|
161
161
|
end
|
162
162
|
|
163
|
+
def add_entry_to_mortar_project_manifest(path, entry)
|
164
|
+
contents = File.open(path, "r") do |manifest|
|
165
|
+
manifest.read.strip
|
166
|
+
end
|
167
|
+
|
168
|
+
if contents && (! contents.include? entry)
|
169
|
+
new_contents = "#{contents}\n#{entry}\n"
|
170
|
+
File.open(path, "w") do |manifest|
|
171
|
+
manifest.write new_contents
|
172
|
+
end
|
173
|
+
end
|
174
|
+
end
|
175
|
+
|
163
176
|
def add_newline_to_file(path)
|
164
177
|
File.open(path, "r+") do |manifest|
|
165
178
|
contents = manifest.read()
|
@@ -189,12 +202,25 @@ module Mortar
|
|
189
202
|
#
|
190
203
|
def ensure_valid_mortar_project_manifest()
|
191
204
|
if File.exists? project_manifest_name
|
205
|
+
ensure_luigiscripts_in_project_manifest()
|
192
206
|
add_newline_to_file(project_manifest_name)
|
193
207
|
else
|
194
208
|
create_mortar_project_manifest('.')
|
195
209
|
end
|
196
210
|
end
|
197
211
|
|
212
|
+
#
|
213
|
+
# Ensure that the luigiscripts directory,
|
214
|
+
# which was added after some project manifests were
|
215
|
+
# created, is in the manifest (if luigiscripts exists).
|
216
|
+
#
|
217
|
+
def ensure_luigiscripts_in_project_manifest
|
218
|
+
luigiscripts_path = "luigiscripts"
|
219
|
+
if File.directory? luigiscripts_path
|
220
|
+
add_entry_to_mortar_project_manifest(project_manifest_name, luigiscripts_path)
|
221
|
+
end
|
222
|
+
end
|
223
|
+
|
198
224
|
#
|
199
225
|
# Create a project manifest file
|
200
226
|
#
|
@@ -207,6 +233,10 @@ module Mortar
|
|
207
233
|
if File.directory? "#{path}/lib"
|
208
234
|
manifest.puts "lib"
|
209
235
|
end
|
236
|
+
|
237
|
+
if File.directory? "#{path}/luigiscripts"
|
238
|
+
manifest.puts "luigiscripts"
|
239
|
+
end
|
210
240
|
end
|
211
241
|
end
|
212
242
|
|
@@ -230,10 +230,14 @@ README
|
|
230
230
|
pig.launch_repl(pig_version, pig_parameters)
|
231
231
|
end
|
232
232
|
|
233
|
-
def run_luigi(pig_version, luigi_script,
|
233
|
+
def run_luigi(pig_version, luigi_script, luigi_script_parameters, project_config_parameters)
|
234
|
+
require_aws_keys
|
234
235
|
install_and_configure(pig_version, 'luigi')
|
235
236
|
py = Mortar::Local::Python.new()
|
236
|
-
py.
|
237
|
+
unless py.run_stillson_luigi_client_cfg_expansion(luigi_script, project_config_parameters)
|
238
|
+
error("Unable to expand your configuration template [luigiscripts/client.cfg.template] to [luigiscripts/client.cfg]")
|
239
|
+
end
|
240
|
+
py.run_luigi_script(luigi_script, luigi_script_parameters)
|
237
241
|
end
|
238
242
|
|
239
243
|
def sqoop_export_table(pig_version, connstr, dbtable, s3dest, options)
|
@@ -198,7 +198,12 @@ module Mortar
|
|
198
198
|
|
199
199
|
def url_date(url, command=nil)
|
200
200
|
result = head_resource(url, command)
|
201
|
-
|
201
|
+
last_modified = result.get_header('Last-Modified')
|
202
|
+
if last_modified
|
203
|
+
http_date_to_epoch(last_modified)
|
204
|
+
else
|
205
|
+
nil
|
206
|
+
end
|
202
207
|
end
|
203
208
|
|
204
209
|
# Given a subdirectory where we have installed some software
|
@@ -211,6 +216,9 @@ module Mortar
|
|
211
216
|
return true
|
212
217
|
end
|
213
218
|
remote_archive_date = url_date(url, command)
|
219
|
+
if not remote_archive_date
|
220
|
+
return false
|
221
|
+
end
|
214
222
|
return existing_install_date < remote_archive_date
|
215
223
|
end
|
216
224
|
|
@@ -0,0 +1,68 @@
|
|
1
|
+
#
|
2
|
+
# Copyright 2014 Mortar Data Inc.
|
3
|
+
#
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
5
|
+
# you may not use this file except in compliance with the License.
|
6
|
+
# You may obtain a copy of the License at
|
7
|
+
#
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
9
|
+
#
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13
|
+
# See the License for the specific language governing permissions and
|
14
|
+
# limitations under the License.
|
15
|
+
#
|
16
|
+
|
17
|
+
require 'set'
|
18
|
+
require 'mortar/auth'
|
19
|
+
|
20
|
+
module Mortar
|
21
|
+
module Local
|
22
|
+
module Params
|
23
|
+
|
24
|
+
# Job parameters that are supplied automatically from Mortar when
|
25
|
+
# running on the server side. We duplicate these here.
|
26
|
+
def automatic_parameters()
|
27
|
+
params = {}
|
28
|
+
|
29
|
+
params['MORTAR_EMAIL'] = Mortar::Auth.user(true)
|
30
|
+
params['MORTAR_API_KEY'] = Mortar::Auth.password(true)
|
31
|
+
|
32
|
+
if ENV['MORTAR_EMAIL_S3_ESCAPED']
|
33
|
+
params['MORTAR_EMAIL_S3_ESCAPED'] = ENV['MORTAR_EMAIL_S3_ESCAPED']
|
34
|
+
else
|
35
|
+
params['MORTAR_EMAIL_S3_ESCAPED'] = Mortar::Auth.user_s3_safe(true)
|
36
|
+
end
|
37
|
+
|
38
|
+
if ENV['MORTAR_PROJECT_ROOT']
|
39
|
+
params['MORTAR_PROJECT_ROOT'] = ENV['MORTAR_PROJECT_ROOT']
|
40
|
+
else
|
41
|
+
params['MORTAR_PROJECT_ROOT'] = project_root
|
42
|
+
ENV['MORTAR_PROJECT_ROOT'] = params['MORTAR_PROJECT_ROOT']
|
43
|
+
end
|
44
|
+
|
45
|
+
params['AWS_ACCESS_KEY'] = ENV['AWS_ACCESS_KEY']
|
46
|
+
params['AWS_ACCESS_KEY_ID'] = ENV['AWS_ACCESS_KEY']
|
47
|
+
params['aws_access_key_id'] = ENV['AWS_ACCESS_KEY']
|
48
|
+
|
49
|
+
params['AWS_SECRET_KEY'] = ENV['AWS_SECRET_KEY']
|
50
|
+
params['AWS_SECRET_ACCESS_KEY'] = ENV['AWS_SECRET_KEY']
|
51
|
+
params['aws_secret_access_key'] = ENV['AWS_SECRET_KEY']
|
52
|
+
|
53
|
+
param_list = params.map do |k,v|
|
54
|
+
{"name" => k, "value" => v}
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
# Merge two lists of parameters, removing dupes.
|
59
|
+
# Parameters in param_list_1 override those in param_list_2
|
60
|
+
def merge_parameters(param_list_0, param_list_1)
|
61
|
+
param_list_1_keys = Set.new(param_list_1.map{|item| item["name"]})
|
62
|
+
merged = param_list_1.clone
|
63
|
+
merged.concat(param_list_0.select{|item| (! param_list_1_keys.include? item["name"]) })
|
64
|
+
merged
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
data/lib/mortar/local/pig.rb
CHANGED
@@ -18,9 +18,11 @@ require "erb"
|
|
18
18
|
require 'tempfile'
|
19
19
|
require "mortar/helpers"
|
20
20
|
require "mortar/local/installutil"
|
21
|
+
require "mortar/local/params"
|
21
22
|
|
22
23
|
class Mortar::Local::Pig
|
23
24
|
include Mortar::Local::InstallUtil
|
25
|
+
include Mortar::Local::Params
|
24
26
|
|
25
27
|
PIG_LOG_FORMAT = "humanreadable"
|
26
28
|
LIB_TGZ_NAME = "lib-common.tar.gz"
|
@@ -399,39 +401,11 @@ class Mortar::Local::Pig
|
|
399
401
|
return opts
|
400
402
|
end
|
401
403
|
|
402
|
-
# Pig Paramenters that are supplied directly from Mortar when
|
403
|
-
# running on the server side. We duplicate these here.
|
404
|
-
def automatic_pig_parameters
|
405
|
-
params = {}
|
406
|
-
|
407
|
-
if ENV['MORTAR_EMAIL_S3_ESCAPED']
|
408
|
-
params['MORTAR_EMAIL_S3_ESCAPED'] = ENV['MORTAR_EMAIL_S3_ESCAPED']
|
409
|
-
else
|
410
|
-
params['MORTAR_EMAIL_S3_ESCAPED'] = Mortar::Auth.user_s3_safe(true)
|
411
|
-
end
|
412
|
-
|
413
|
-
if ENV['MORTAR_PROJECT_ROOT']
|
414
|
-
params['MORTAR_PROJECT_ROOT'] = ENV['MORTAR_PROJECT_ROOT']
|
415
|
-
else
|
416
|
-
params['MORTAR_PROJECT_ROOT'] = project_root
|
417
|
-
ENV['MORTAR_PROJECT_ROOT'] = params['MORTAR_PROJECT_ROOT']
|
418
|
-
end
|
419
|
-
|
420
|
-
|
421
|
-
# Coerce into the same format as pig parameters that were
|
422
|
-
# passed in via the command line or a parameter file
|
423
|
-
param_list = []
|
424
|
-
params.each{ |k,v|
|
425
|
-
param_list.push({"name" => k, "value" => v})
|
426
|
-
}
|
427
|
-
return param_list
|
428
|
-
end
|
429
|
-
|
430
404
|
# Given a set of user specified pig parameters, combine with the
|
431
405
|
# automatic mortar parameters and write out to a tempfile, returning
|
432
406
|
# it's path so it may be referenced later in the process
|
433
407
|
def make_pig_param_file(pig_parameters)
|
434
|
-
mortar_pig_params =
|
408
|
+
mortar_pig_params = automatic_parameters()
|
435
409
|
all_parameters = mortar_pig_params.concat(pig_parameters)
|
436
410
|
param_file = Tempfile.new("mortar-pig-parameters")
|
437
411
|
all_parameters.each { |p|
|
@@ -447,4 +421,9 @@ class Mortar::Local::Pig
|
|
447
421
|
param_file.path
|
448
422
|
end
|
449
423
|
|
424
|
+
def automatic_pig_parameters
|
425
|
+
warn "[DEPRECATION] Please call automatic_parameters instead"
|
426
|
+
automatic_parameters
|
427
|
+
end
|
428
|
+
|
450
429
|
end
|