wukong-hadoop 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +59 -0
- data/.rspec +2 -0
- data/Gemfile +3 -0
- data/README.md +339 -0
- data/Rakefile +13 -0
- data/bin/hdp-bin +44 -0
- data/bin/hdp-bzip +23 -0
- data/bin/hdp-cat +3 -0
- data/bin/hdp-catd +3 -0
- data/bin/hdp-cp +3 -0
- data/bin/hdp-du +86 -0
- data/bin/hdp-get +3 -0
- data/bin/hdp-kill +3 -0
- data/bin/hdp-kill-task +3 -0
- data/bin/hdp-ls +11 -0
- data/bin/hdp-mkdir +2 -0
- data/bin/hdp-mkdirp +12 -0
- data/bin/hdp-mv +3 -0
- data/bin/hdp-parts_to_keys.rb +77 -0
- data/bin/hdp-ps +3 -0
- data/bin/hdp-put +3 -0
- data/bin/hdp-rm +32 -0
- data/bin/hdp-sort +40 -0
- data/bin/hdp-stream +40 -0
- data/bin/hdp-stream-flat +22 -0
- data/bin/hdp-stream2 +39 -0
- data/bin/hdp-sync +17 -0
- data/bin/hdp-wc +67 -0
- data/bin/wu-hadoop +14 -0
- data/examples/counter.rb +17 -0
- data/examples/map_only.rb +28 -0
- data/examples/processors.rb +4 -0
- data/examples/sonnet_18.txt +14 -0
- data/examples/tokenizer.rb +28 -0
- data/examples/word_count.rb +44 -0
- data/features/step_definitions/wu_hadoop_steps.rb +4 -0
- data/features/support/env.rb +1 -0
- data/features/wu_hadoop.feature +113 -0
- data/lib/wukong-hadoop.rb +21 -0
- data/lib/wukong-hadoop/configuration.rb +133 -0
- data/lib/wukong-hadoop/driver.rb +190 -0
- data/lib/wukong-hadoop/driver/hadoop_invocation.rb +184 -0
- data/lib/wukong-hadoop/driver/inputs_and_outputs.rb +27 -0
- data/lib/wukong-hadoop/driver/local_invocation.rb +48 -0
- data/lib/wukong-hadoop/driver/map_logic.rb +104 -0
- data/lib/wukong-hadoop/driver/reduce_logic.rb +129 -0
- data/lib/wukong-hadoop/extensions.rb +2 -0
- data/lib/wukong-hadoop/hadoop_env_methods.rb +80 -0
- data/lib/wukong-hadoop/version.rb +6 -0
- data/spec/spec_helper.rb +21 -0
- data/spec/support/driver_helper.rb +15 -0
- data/spec/support/integration_helper.rb +39 -0
- data/spec/wukong-hadoop/driver_spec.rb +117 -0
- data/spec/wukong-hadoop/hadoop_env_methods_spec.rb +14 -0
- data/spec/wukong-hadoop/hadoop_mode_spec.rb +78 -0
- data/spec/wukong-hadoop/local_mode_spec.rb +22 -0
- data/spec/wukong-hadoop/wu_hadoop_spec.rb +34 -0
- data/wukong-hadoop.gemspec +33 -0
- metadata +168 -0
data/bin/hdp-bzip
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
#!/bin/bash
|
2
|
+
|
3
|
+
HADOOP_HOME=${HADOOP_HOME-/usr/lib/hadoop}
|
4
|
+
|
5
|
+
input_file=${1} ; shift
|
6
|
+
output_file=${1} ; shift
|
7
|
+
|
8
|
+
if [ "$output_file" == "" ] ; then echo "$0 input_file output_file" ; exit ; fi
|
9
|
+
|
10
|
+
HADOOP_HOME=${HADOOP_HOME-/usr/lib/hadoop}
|
11
|
+
|
12
|
+
cmd="${HADOOP_HOME}/bin/hadoop \
|
13
|
+
jar ${HADOOP_HOME}/contrib/streaming/hadoop-*streaming*.jar \
|
14
|
+
-Dmapred.output.compress=true \
|
15
|
+
-Dmapred.output.compression.codec=org.apache.hadoop.io.compress.BZip2Codec \
|
16
|
+
-Dmapred.reduce.tasks=1 \
|
17
|
+
-mapper \"/bin/cat\" \
|
18
|
+
-reducer \"/bin/cat\" \
|
19
|
+
-input \"$input_file\" \
|
20
|
+
-output \"$output_file\" \
|
21
|
+
"
|
22
|
+
echo $cmd
|
23
|
+
$cmd
|
data/bin/hdp-cat
ADDED
data/bin/hdp-catd
ADDED
data/bin/hdp-cp
ADDED
data/bin/hdp-du
ADDED
@@ -0,0 +1,86 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
OPTIONS={}
|
4
|
+
|
5
|
+
#
|
6
|
+
# grok options
|
7
|
+
#
|
8
|
+
if ARGV[0] =~ /\A-[sh]+\z/
|
9
|
+
flags = ARGV.shift
|
10
|
+
OPTIONS[:summary] = flags.include?('s')
|
11
|
+
OPTIONS[:humanize] = flags.include?('h')
|
12
|
+
end
|
13
|
+
|
14
|
+
#
|
15
|
+
# Prepare command
|
16
|
+
#
|
17
|
+
def prepare_command
|
18
|
+
dfs_cmd = OPTIONS[:summary] ? 'dus' : 'du'
|
19
|
+
dfs_args = ((!ARGV[0]) || ARGV[0]=='') ? '.' : "'#{ARGV.join("' '")}'"
|
20
|
+
%Q{ hadoop dfs -#{dfs_cmd} #{dfs_args} }
|
21
|
+
end
|
22
|
+
|
23
|
+
Numeric.class_eval do
|
24
|
+
def bytes() self ; end
|
25
|
+
alias :byte :bytes
|
26
|
+
def kilobytes() self * 1024 ; end
|
27
|
+
alias :kilobyte :kilobytes
|
28
|
+
def megabytes() self * 1024.kilobytes ; end
|
29
|
+
alias :megabyte :megabytes
|
30
|
+
def gigabytes() self * 1024.megabytes ; end
|
31
|
+
alias :gigabyte :gigabytes
|
32
|
+
def terabytes() self * 1024.gigabytes ; end
|
33
|
+
alias :terabyte :terabytes
|
34
|
+
def petabytes() self * 1024.terabytes ; end
|
35
|
+
alias :petabyte :petabytes
|
36
|
+
def exabytes() self * 1024.petabytes ; end
|
37
|
+
alias :exabyte :exabytes
|
38
|
+
end
|
39
|
+
|
40
|
+
# Formats the bytes in +size+ into a more understandable representation
|
41
|
+
# (e.g., giving it 1500 yields 1.5 KB). This method is useful for
|
42
|
+
# reporting file sizes to users. This method returns nil if
|
43
|
+
# +size+ cannot be converted into a number. You can change the default
|
44
|
+
# precision of 1 using the precision parameter +precision+.
|
45
|
+
#
|
46
|
+
# ==== Examples
|
47
|
+
# number_to_human_size(123) # => 123 Bytes
|
48
|
+
# number_to_human_size(1234) # => 1.2 KB
|
49
|
+
# number_to_human_size(12345) # => 12.1 KB
|
50
|
+
# number_to_human_size(1234567) # => 1.2 MB
|
51
|
+
# number_to_human_size(1234567890) # => 1.1 GB
|
52
|
+
# number_to_human_size(1234567890123) # => 1.1 TB
|
53
|
+
# number_to_human_size(1234567, 2) # => 1.18 MB
|
54
|
+
# number_to_human_size(483989, 0) # => 4 MB
|
55
|
+
def number_to_human_size(size, precision=1)
|
56
|
+
size = Kernel.Float(size)
|
57
|
+
case
|
58
|
+
when size.to_i == 1; "1 Byte"
|
59
|
+
when size < 1.kilobyte; "%d Bytes" % size
|
60
|
+
when size < 1.megabyte; "%.#{precision}f KB" % (size / 1.0.kilobyte)
|
61
|
+
when size < 1.gigabyte; "%.#{precision}f MB" % (size / 1.0.megabyte)
|
62
|
+
when size < 1.terabyte; "%.#{precision}f GB" % (size / 1.0.gigabyte)
|
63
|
+
else "%.#{precision}f TB" % (size / 1.0.terabyte)
|
64
|
+
end #.sub(/([0-9]\.\d*?)0+ /, '\1 ' ).sub(/\. /,' ')
|
65
|
+
rescue
|
66
|
+
nil
|
67
|
+
end
|
68
|
+
|
69
|
+
OUTPUT_LINE_FMT = "%-71s\t%15d\t%15s"
|
70
|
+
def format_output file, size
|
71
|
+
human_size = number_to_human_size(size) || ""
|
72
|
+
file = file.gsub(%r{hdfs://[^/]+/}, '/') # kill off hdfs paths, otherwise leave it alone
|
73
|
+
OUTPUT_LINE_FMT % [file, size.to_i, human_size]
|
74
|
+
end
|
75
|
+
|
76
|
+
entries_count = 0
|
77
|
+
total_size = 0
|
78
|
+
%x{ #{prepare_command} }.split("\n").each do |line|
|
79
|
+
if line =~ /^Found \d+ items$/ then puts line ; next end
|
80
|
+
info = line.split(/\s+/)
|
81
|
+
if OPTIONS[:summary] then file, size = info else size, file = info end
|
82
|
+
puts format_output(file, size)
|
83
|
+
total_size += size.to_i
|
84
|
+
entries_count += 1
|
85
|
+
end
|
86
|
+
$stderr.puts OUTPUT_LINE_FMT%[" #{"%55d"%entries_count} entries", total_size, number_to_human_size(total_size)]
|
data/bin/hdp-get
ADDED
data/bin/hdp-kill
ADDED
data/bin/hdp-kill-task
ADDED
data/bin/hdp-ls
ADDED
data/bin/hdp-mkdir
ADDED
data/bin/hdp-mkdirp
ADDED
@@ -0,0 +1,12 @@
|
|
1
|
+
#!/usr/bin/env bash
|
2
|
+
|
3
|
+
#
|
4
|
+
# Despite arguments and deliberation, this IS a necessary script. Azkaban, should you choose to
|
5
|
+
# use it, will fail if (it seems) ANY of its spawned subprocesses fails
|
6
|
+
#
|
7
|
+
|
8
|
+
hadoop fs -test -e "$@"
|
9
|
+
if [ "$?" != "0" ] ; then
|
10
|
+
# echo "File does not exist, making..."
|
11
|
+
exec hadoop fs -mkdir "$@"
|
12
|
+
fi
|
data/bin/hdp-mv
ADDED
@@ -0,0 +1,77 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
dir_to_rename = ARGV[0]
|
4
|
+
dest_ext = '.tsv'
|
5
|
+
|
6
|
+
unless dir_to_rename && (! dir_to_rename.empty?)
|
7
|
+
warn "Need a directory or file spec to rename."
|
8
|
+
exit
|
9
|
+
end
|
10
|
+
|
11
|
+
#
|
12
|
+
# Setup
|
13
|
+
#
|
14
|
+
warn "\nPlease IGNORE the 'cat: Unable to write to output stream.' errors\n"
|
15
|
+
|
16
|
+
#
|
17
|
+
# Examine the files
|
18
|
+
#
|
19
|
+
file_listings = `hdp-ls #{dir_to_rename}`.split("\n")
|
20
|
+
command_lists = { }
|
21
|
+
file_listings[1..-1].each do |file_listing|
|
22
|
+
m = %r{[-drwx]+\s+[\-\d]+\s+\w+\s+\w+\s+(\d+)\s+[\d\-]+\s+[\d\:]+\s+(.+)$}.match(file_listing)
|
23
|
+
if !m then warn "Couldn't grok #{file_listing}" ; next ; end
|
24
|
+
size, filename = m.captures
|
25
|
+
case
|
26
|
+
when size.to_i == 0 then (command_lists[:deletes]||=[]) << filename
|
27
|
+
else
|
28
|
+
firstline = `hdp-cat #{filename} | head -qn1 `
|
29
|
+
file_key, _ = firstline.split("\t", 2)
|
30
|
+
unless file_key && (file_key =~ /\A[\w\-\.]+\z/)
|
31
|
+
warn "Don't want to rename to '#{file_key}'... skipping"
|
32
|
+
next
|
33
|
+
end
|
34
|
+
dirname = File.dirname(filename)
|
35
|
+
destfile = File.join(dirname, file_key)+dest_ext
|
36
|
+
(command_lists[:moves]||=[]) << "hdp-mv #{filename} #{destfile}"
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
#
|
41
|
+
# Execute the command_lists
|
42
|
+
#
|
43
|
+
command_lists.each do |type, command_list|
|
44
|
+
case type
|
45
|
+
when :deletes
|
46
|
+
command = "hdp-rm #{command_list.join(" ")}"
|
47
|
+
puts command
|
48
|
+
`#{command}`
|
49
|
+
when :moves
|
50
|
+
command_list.each do |command|
|
51
|
+
puts command
|
52
|
+
`#{command}`
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
|
58
|
+
# -rw-r--r-- 3 flip supergroup 0 2008-12-20 05:51 /user/flip/out/sorted-tweets-20081220/part-00010
|
59
|
+
|
60
|
+
# # Killing empty files
|
61
|
+
# find . -size 0 -print -exec rm {} \;
|
62
|
+
#
|
63
|
+
# for foo in part-0* ; do
|
64
|
+
# newname=`
|
65
|
+
# head -n1 $foo |
|
66
|
+
# cut -d' ' -f1 |
|
67
|
+
# ruby -ne 'puts $_.chomp.gsub(/[^\-\w]/){|s| s.bytes.map{|c| "%%%02X" % c }}'
|
68
|
+
# `.tsv ;
|
69
|
+
# echo "moving $foo to $newname"
|
70
|
+
# mv "$foo" "$newname"
|
71
|
+
# done
|
72
|
+
#
|
73
|
+
# # dir=`basename $PWD`
|
74
|
+
# # for foo in *.tsv ; do
|
75
|
+
# # echo "Compressing $dir"
|
76
|
+
# # bzip2 -c $foo > ../$dir-bz2/$foo.bz2
|
77
|
+
# # done
|
data/bin/hdp-ps
ADDED
data/bin/hdp-put
ADDED
data/bin/hdp-rm
ADDED
@@ -0,0 +1,32 @@
|
|
1
|
+
#!/usr/bin/env bash
|
2
|
+
|
3
|
+
#
|
4
|
+
# Documentation for hadoop fs -rmr says "acts just like Unix rm -rf command". If this
|
5
|
+
# is true then we need to ignore directories that don't exist and still return 0.
|
6
|
+
#
|
7
|
+
|
8
|
+
#
|
9
|
+
# All the dirty conditional logic here does is test whether a directory exists. If so, remove it
|
10
|
+
#
|
11
|
+
if [ "$1" == "-r" ] ; then
|
12
|
+
shift
|
13
|
+
if [ "$1" == "-skipTrash" ] ; then
|
14
|
+
shift
|
15
|
+
hadoop fs -test -e "$@"
|
16
|
+
if [ "$?" == "0" ] ; then
|
17
|
+
# echo "File exists, skipping trash, removing it..."
|
18
|
+
echo hadoop dfs -rmr -skipTrash "$@"
|
19
|
+
exec hadoop dfs -rmr -skipTrash "$@"
|
20
|
+
fi
|
21
|
+
else
|
22
|
+
hadoop fs -test -e "$@"
|
23
|
+
if [ "$?" == "0" ] ; then
|
24
|
+
# echo "File exists, removing it..."
|
25
|
+
echo hadoop dfs -rmr "$@"
|
26
|
+
exec hadoop dfs -rmr "$@"
|
27
|
+
fi
|
28
|
+
fi
|
29
|
+
else
|
30
|
+
echo hadoop dfs -rm "$@"
|
31
|
+
exec hadoop dfs -rm "$@"
|
32
|
+
fi
|
data/bin/hdp-sort
ADDED
@@ -0,0 +1,40 @@
|
|
1
|
+
#!/usr/bin/env bash
|
2
|
+
|
3
|
+
input_file=${1} ; shift
|
4
|
+
output_file=${1} ; shift
|
5
|
+
map_script=${1-/bin/cat} ; shift
|
6
|
+
reduce_script=${1-/usr/bin/uniq} ; shift
|
7
|
+
partfields=${1-2} ; shift
|
8
|
+
sortfields=${1-2} ; shift
|
9
|
+
|
10
|
+
if [ "$output_file" == "" ] ; then echo "$0 input_file output_file [mapper=/bin/cat] [reducer=/usr/bin/uniq] [partfields=2] [sortfields=2] [extra_args]" ; exit ; fi
|
11
|
+
|
12
|
+
HADOOP_HOME=${HADOOP_HOME-/usr/lib/hadoop}
|
13
|
+
|
14
|
+
cmd="${HADOOP_HOME}/bin/hadoop \
|
15
|
+
jar ${HADOOP_HOME}/contrib/streaming/hadoop-*streaming*.jar
|
16
|
+
$@
|
17
|
+
-D num.key.fields.for.partition=\"$partfields\"
|
18
|
+
-D stream.num.map.output.key.fields=\"$sortfields\"
|
19
|
+
-D stream.map.output.field.separator=\"'/t'\"
|
20
|
+
-D mapred.text.key.partitioner.options=\"-k1,$partfields\"
|
21
|
+
-D mapred.job.name=\"`basename $0`-$map_script-$input_file-$output_file\"
|
22
|
+
-partitioner org.apache.hadoop.mapred.lib.KeyFieldBasedPartitioner
|
23
|
+
-mapper \"$map_script\"
|
24
|
+
-reducer \"$reduce_script\"
|
25
|
+
-input \"$input_file\"
|
26
|
+
-output \"$output_file\"
|
27
|
+
"
|
28
|
+
|
29
|
+
echo "$cmd"
|
30
|
+
|
31
|
+
$cmd
|
32
|
+
|
33
|
+
# For a map-side-only job specify
|
34
|
+
# -jobconf mapred.reduce.tasks=0 \
|
35
|
+
|
36
|
+
# Maybe?
|
37
|
+
#
|
38
|
+
# -inputformat org.apache.hadoop.mapred.KeyValueTextInputFormat \
|
39
|
+
# -mapper org.apache.hadoop.mapred.lib.IdentityMapper \
|
40
|
+
#
|
data/bin/hdp-stream
ADDED
@@ -0,0 +1,40 @@
|
|
1
|
+
#!/usr/bin/env bash
|
2
|
+
|
3
|
+
input_file=${1} ; shift
|
4
|
+
output_file=${1} ; shift
|
5
|
+
map_script=${1-/bin/cat} ; shift
|
6
|
+
reduce_script=${1-/usr/bin/uniq} ; shift
|
7
|
+
partfields=${1-2} ; shift
|
8
|
+
sortfields=${1-2} ; shift
|
9
|
+
|
10
|
+
if [ "$output_file" == "" ] ; then echo "$0 input_file output_file [mapper=/bin/cat] [reducer=/usr/bin/uniq] [partfields=2] [sortfields=2] [extra_args]" ; exit ; fi
|
11
|
+
|
12
|
+
HADOOP_HOME=${HADOOP_HOME-/usr/lib/hadoop}
|
13
|
+
|
14
|
+
cmd="${HADOOP_HOME}/bin/hadoop \
|
15
|
+
jar ${HADOOP_HOME}/contrib/streaming/hadoop-*streaming*.jar
|
16
|
+
$@
|
17
|
+
-D num.key.fields.for.partition=\"$partfields\"
|
18
|
+
-D stream.num.map.output.key.fields=\"$sortfields\"
|
19
|
+
-D stream.map.output.field.separator=\"'/t'\"
|
20
|
+
-D mapred.text.key.partitioner.options=\"-k1,$partfields\"
|
21
|
+
-D mapred.job.name=\"`basename $0`-$map_script-$input_file-$output_file\"
|
22
|
+
-partitioner org.apache.hadoop.mapred.lib.KeyFieldBasedPartitioner
|
23
|
+
-mapper \"$map_script\"
|
24
|
+
-reducer \"$reduce_script\"
|
25
|
+
-input \"$input_file\"
|
26
|
+
-output \"$output_file\"
|
27
|
+
"
|
28
|
+
|
29
|
+
echo "$cmd"
|
30
|
+
|
31
|
+
$cmd
|
32
|
+
|
33
|
+
# For a map-side-only job specify
|
34
|
+
# -jobconf mapred.reduce.tasks=0 \
|
35
|
+
|
36
|
+
# Maybe?
|
37
|
+
#
|
38
|
+
# -inputformat org.apache.hadoop.mapred.KeyValueTextInputFormat \
|
39
|
+
# -mapper org.apache.hadoop.mapred.lib.IdentityMapper \
|
40
|
+
#
|
data/bin/hdp-stream-flat
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
#!/usr/bin/env bash
|
2
|
+
|
3
|
+
input_file="${1}" ; shift
|
4
|
+
output_file="${1}" ; shift
|
5
|
+
map_script="${1-/bin/cat}" ; shift
|
6
|
+
reduce_script="${1-/usr/bin/uniq}" ; shift
|
7
|
+
|
8
|
+
if [ "$output_file" == "" ] ; then echo "$0 input_file output_file [mapper=/bin/cat] [reducer=/usr/bin/uniq] [extra_args]" ; exit ; fi
|
9
|
+
|
10
|
+
HADOOP_HOME=${HADOOP_HOME-/usr/lib/hadoop}
|
11
|
+
|
12
|
+
# Can add fun stuff like
|
13
|
+
# -Dmapred.reduce.tasks=0 \
|
14
|
+
|
15
|
+
exec ${HADOOP_HOME}/bin/hadoop \
|
16
|
+
jar ${HADOOP_HOME}/contrib/streaming/hadoop-*streaming*.jar \
|
17
|
+
"$@" \
|
18
|
+
-Dmapred.job.name=`basename $0`-$map_script-$input_file-$output_file \
|
19
|
+
-mapper "$map_script" \
|
20
|
+
-reducer "$reduce_script" \
|
21
|
+
-input "$input_file" \
|
22
|
+
-output "$output_file"
|
data/bin/hdp-stream2
ADDED
@@ -0,0 +1,39 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
require 'wukong'
|
3
|
+
|
4
|
+
# Example usage:
|
5
|
+
#
|
6
|
+
# ~/ics/wukong/bin/hdp-stream2 input_path1,input_path2 output_path \
|
7
|
+
# "`which cuttab` 2,3,7" "`which uniq` -c" 1 3 -jobconf mapred.reduce.tasks=23
|
8
|
+
|
9
|
+
|
10
|
+
# options = Settings[:runner_defaults].dup
|
11
|
+
|
12
|
+
# cmdline_opts = Hash.zip(
|
13
|
+
# [ :input_file, :output_file,
|
14
|
+
# :map_command, :reduce_command,
|
15
|
+
# :partition_fields, :sort_fields],
|
16
|
+
# ARGV.map{|s| s.blank? ? nil : s }
|
17
|
+
# )
|
18
|
+
# argvs = ARGV.slice!(0..5) ;
|
19
|
+
# ARGV.unshift cmdline_opts[:input_file];
|
20
|
+
# ARGV.unshift cmdline_opts[:output_file]
|
21
|
+
# p [argvs, ARGV]
|
22
|
+
#
|
23
|
+
# # cmdline_opts[:map_command] = `which cat`.chomp if cmdline_opts[:map_command].blank?
|
24
|
+
# # cmdline_opts[:reduce_command] = nil if cmdline_opts[:reduce_command].blank?
|
25
|
+
# cmdline_opts[:dry_run] = true
|
26
|
+
# cmdline_opts[:run] = true
|
27
|
+
|
28
|
+
#p cmdline_opts, Settings[:runner_defaults]
|
29
|
+
|
30
|
+
# Go script go!
|
31
|
+
runner = Wukong::Script.new(
|
32
|
+
nil, # use mapper_command
|
33
|
+
nil, # use reducer_command
|
34
|
+
:run => true
|
35
|
+
)
|
36
|
+
# runner.options.merge cmdline_opts
|
37
|
+
runner.options[:reuse_jvms] = true if runner.options[:reuse_jvms].blank?
|
38
|
+
|
39
|
+
runner.run
|