jobmanager 1.0.1 → 1.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.
- data/History.txt +7 -1
- data/Manifest.txt +6 -2
- data/README.txt +66 -32
- data/Rakefile +2 -2
- data/examples/jobmanager.yaml +19 -9
- data/lib/jobmanager/application.rb +32 -31
- data/lib/jobmanager/applicationconfig.rb +15 -3
- data/lib/jobmanager/applicationoptionparser.rb +19 -5
- data/lib/jobmanager/openonfirstwritefile.rb +65 -0
- data/lib/jobmanager/system.rb +15 -8
- data/lib/jobmanager/teestream.rb +21 -39
- data/lib/jobmanager/timestampedstream.rb +89 -0
- data/test/configs/all_values.yaml +7 -1
- data/test/configs/jobmanager_4_bad_job_logs_directory_1.yaml +1 -1
- data/test/configs/{jobmanager_4_bad_job_logs_directory_2.yaml → jobmanager_5_bad_job_log_file.yaml} +0 -0
- data/test/configs/jobmanager_6.yaml +9 -0
- data/test/configs/jobmanager_7_no_log_rotation.yaml +15 -0
- data/test/test_applicationlogger.rb +3 -0
- data/test/test_config.rb +33 -22
- data/test/test_jobmanager.rb +133 -62
- data/test/test_openonfirstwritefile.rb +71 -0
- data/test/test_system.rb +3 -3
- data/test/test_teestream.rb +45 -2
- metadata +11 -6
- data/examples/mysql_backup.rb +0 -18
@@ -13,13 +13,13 @@ module JobManager
|
|
13
13
|
#
|
14
14
|
class ApplicationConfig < ConfigToolkit::BaseConfig
|
15
15
|
|
16
|
-
EMAIL_CONDITIONS = [ :always, :never, :on_failure ]
|
16
|
+
EMAIL_CONDITIONS = [ :always, :never, :on_failure, :on_job_output_or_failure ]
|
17
17
|
CENTRAL_LOG_MODES = [ :syslog, :file ]
|
18
18
|
|
19
19
|
add_required_param(:command, String)
|
20
20
|
|
21
21
|
add_required_param(:job_name, String)
|
22
|
-
|
22
|
+
|
23
23
|
add_required_param(:job_logs_directory, Pathname)
|
24
24
|
|
25
25
|
add_optional_param(:email_condition, Symbol, :on_failure) do |value|
|
@@ -71,9 +71,21 @@ module JobManager
|
|
71
71
|
"jobmanager results for <%=job_name%> on <%=host_name%> : <%=result%>")
|
72
72
|
|
73
73
|
add_optional_param(:email_from_address, String)
|
74
|
-
|
74
|
+
|
75
75
|
add_optional_param(:email_to_address, String)
|
76
76
|
|
77
|
+
add_optional_param(:rotate_job_log, ConfigToolkit::Boolean, true)
|
78
|
+
|
79
|
+
add_optional_param(:job_log_prepend_date_time, ConfigToolkit::Boolean, false)
|
80
|
+
|
81
|
+
add_optional_param(:job_log_prepend_date_time_format, String, '%F_%T') do |value|
|
82
|
+
begin
|
83
|
+
DateTime.now.strftime(value)
|
84
|
+
rescue => e
|
85
|
+
raise_error("Invalid format #{e.message}")
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
77
89
|
def validate_all_values()
|
78
90
|
if (self.central_log_mode == :file && !self.central_log_file?)
|
79
91
|
raise_error("If central_log_mode is set to file, central_log_file must be specified.")
|
@@ -22,7 +22,7 @@ module JobManager
|
|
22
22
|
# The command line arguments.
|
23
23
|
# ====Returns:
|
24
24
|
# A hash of the command line options and arugments.
|
25
|
-
#
|
25
|
+
#
|
26
26
|
def self.parse(program_name, args)
|
27
27
|
options = {}
|
28
28
|
opts = OptionParser.new
|
@@ -89,9 +89,23 @@ module JobManager
|
|
89
89
|
"Timeout (in seconds) after which the script is killed.") do |timeout|
|
90
90
|
options["timeout"] = timeout
|
91
91
|
end
|
92
|
-
|
92
|
+
|
93
|
+
opts.on("-p", "--[no-]job_log_prepend_date_time",
|
94
|
+
"Whether to prepend the date/time to each line of the job log file.") do |value|
|
95
|
+
options["job_log_prepend_date_time"] = value
|
96
|
+
end
|
97
|
+
|
98
|
+
opts.on("-g", "--job_log_prepend_date_time_format FORMAT",
|
99
|
+
"The format of the date/time to be prepended to each line of the job log file.") do |format|
|
100
|
+
options["job_log_prepend_date_time_format"] = format
|
101
|
+
end
|
102
|
+
|
103
|
+
opts.on("-b", "--[no-]rotate_job_log",
|
104
|
+
"Whether the job log file should be rotated.") do |value|
|
105
|
+
options["rotate_job_log"] = value
|
106
|
+
end
|
107
|
+
|
93
108
|
opts.on("-e", "--email_settings_file PATH",
|
94
|
-
String,
|
95
109
|
"The configuration file for the simpleemail gem.",
|
96
110
|
"Note: If a relative path is specified, it will be considered to be relative to the",
|
97
111
|
"location of the configuration file.") do |file|
|
@@ -102,8 +116,8 @@ module JobManager
|
|
102
116
|
"The email address from which jobmanager sends results.") do |address|
|
103
117
|
options["email_from_address"] = address
|
104
118
|
end
|
105
|
-
|
106
|
-
opts.on("-
|
119
|
+
|
120
|
+
opts.on("-u", "--email_to_address ADDRESS",
|
107
121
|
"The email address to which jobmanager sends results.") do |address|
|
108
122
|
options["email_to_address"] = address
|
109
123
|
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
module JobManager
|
2
|
+
|
3
|
+
#
|
4
|
+
# This class represents a stream that wraps a file stream.
|
5
|
+
# Its only purpose is to defer the opening of the file to
|
6
|
+
# first write. Thus, if the file is never written to, it
|
7
|
+
# is never created!
|
8
|
+
#
|
9
|
+
class OpenOnFirstWriteFile
|
10
|
+
|
11
|
+
def initialize(file_name, mode_string)
|
12
|
+
@file_name = file_name
|
13
|
+
@mode_string = mode_string
|
14
|
+
@stream = nil
|
15
|
+
end
|
16
|
+
|
17
|
+
def close()
|
18
|
+
# This method needs to be explicitly defined. If it wasn't,
|
19
|
+
# method_missing would create the file in order to close it,
|
20
|
+
# defeating the purpose of this class.
|
21
|
+
|
22
|
+
if (@stream)
|
23
|
+
@stream.close()
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def flush()
|
28
|
+
# This method needs to be explicitly defined. If it wasn't,
|
29
|
+
# method_missing would create the file in order to flush it,
|
30
|
+
# defeating the purpose of this class.
|
31
|
+
|
32
|
+
if (@stream)
|
33
|
+
@stream.flush()
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
#
|
38
|
+
# ====Description:
|
39
|
+
# This method first opens the file (if not already opened), and then
|
40
|
+
# forwards the call to the file stream.
|
41
|
+
#
|
42
|
+
def method_missing(*args, &block)
|
43
|
+
method = args.shift
|
44
|
+
|
45
|
+
if (!@stream)
|
46
|
+
directory = File.dirname(@file_name)
|
47
|
+
begin
|
48
|
+
FileUtils.mkdir_p(directory)
|
49
|
+
rescue => e
|
50
|
+
raise "Failed to create directory #{directory}, Error (#{e.class}): #{e.message}"
|
51
|
+
end
|
52
|
+
|
53
|
+
begin
|
54
|
+
@stream = File.new(@file_name, @mode_string)
|
55
|
+
rescue => e
|
56
|
+
raise "Failed to open file #{@file_name}, Error (#{e.class}): #{e.message}"
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
@stream.send(method, *args)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
end
|
65
|
+
|
data/lib/jobmanager/system.rb
CHANGED
@@ -62,6 +62,7 @@ module JobManager
|
|
62
62
|
read_pipe, write_pipe = IO.pipe
|
63
63
|
original_time = Time.now()
|
64
64
|
success = false
|
65
|
+
job_log_write_success = true
|
65
66
|
timeout = optional_args[:timeout]
|
66
67
|
env_path = optional_args[:command_path] ? optional_args[:command_path] : ENV["PATH"]
|
67
68
|
|
@@ -122,7 +123,12 @@ module JobManager
|
|
122
123
|
end
|
123
124
|
|
124
125
|
if (bytes.length() > 0)
|
125
|
-
|
126
|
+
begin
|
127
|
+
log_stream << bytes
|
128
|
+
rescue => e
|
129
|
+
logger.error("Error writing to job log: #{e.message}")
|
130
|
+
job_log_write_success = false
|
131
|
+
end
|
126
132
|
end
|
127
133
|
end
|
128
134
|
|
@@ -133,20 +139,21 @@ module JobManager
|
|
133
139
|
if (Process.waitpid(child_pid, Process::WNOHANG))
|
134
140
|
|
135
141
|
# success? will return nil if the child didn't exit normally
|
136
|
-
|
142
|
+
child_success = $CHILD_STATUS.success?() ? true : false;
|
137
143
|
pid = $CHILD_STATUS.pid()
|
138
144
|
|
139
|
-
if (
|
140
|
-
logger.info("
|
145
|
+
if (child_success)
|
146
|
+
logger.info("Job exited successfully")
|
141
147
|
else
|
142
148
|
if ($CHILD_STATUS.exited?())
|
143
|
-
logger.error("Failed! - exited normally with exit code: #{$CHILD_STATUS.exitstatus()}.")
|
149
|
+
logger.error("Failed! - Job exited normally with exit code: #{$CHILD_STATUS.exitstatus()}.")
|
144
150
|
elsif ($CHILD_STATUS.signaled?())
|
145
|
-
logger.error("Failed! - exited abnormally due to signal: #{$CHILD_STATUS.termsig()}.")
|
151
|
+
logger.error("Failed! - Job exited abnormally due to signal: #{$CHILD_STATUS.termsig()}.")
|
146
152
|
else
|
147
|
-
logger.error("Failed! - exited abnormally: #{$CHILD_STATUS.inspect()}")
|
153
|
+
logger.error("Failed! - Job exited abnormally: #{$CHILD_STATUS.inspect()}")
|
148
154
|
end
|
149
155
|
end
|
156
|
+
success = child_success && job_log_write_success
|
150
157
|
|
151
158
|
break
|
152
159
|
else
|
@@ -213,7 +220,7 @@ module JobManager
|
|
213
220
|
exit(1)
|
214
221
|
end
|
215
222
|
end
|
216
|
-
|
223
|
+
|
217
224
|
|
218
225
|
#
|
219
226
|
# ====Description:
|
data/lib/jobmanager/teestream.rb
CHANGED
@@ -6,8 +6,10 @@ module JobManager
|
|
6
6
|
# This class represents a stream whose only purpose is to forward
|
7
7
|
# messages on to a list of configured streams.
|
8
8
|
#
|
9
|
-
class TeeStream
|
9
|
+
class TeeStream
|
10
10
|
|
11
|
+
attr_reader :streams
|
12
|
+
|
11
13
|
#
|
12
14
|
# ====Description:
|
13
15
|
# This method creates an TeeStream instance initialized with the
|
@@ -30,49 +32,29 @@ module JobManager
|
|
30
32
|
# The name of the stream requested.
|
31
33
|
# ====Returns:
|
32
34
|
# The associated stream if it exists, nil otherwise
|
33
|
-
def
|
35
|
+
def stream(name)
|
34
36
|
@streams[name]
|
35
37
|
end
|
36
38
|
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
# streams. Note: This class is derived from IO, and thus only
|
41
|
-
# needs to implement the write method in order for all of the
|
42
|
-
# write-related methods (<<, print, puts, putc, etc) to work free
|
43
|
-
# of charge, as all of these methods call write.
|
44
|
-
# ====Parameters:
|
45
|
-
# [str]
|
46
|
-
# The string to be written.
|
47
|
-
# ====Returns:
|
48
|
-
# The length of the string.
|
49
|
-
#
|
50
|
-
def write(str)
|
51
|
-
@streams.each_value do |stream|
|
52
|
-
stream.write(str)
|
53
|
-
end
|
39
|
+
def method_missing(*args, &block)
|
40
|
+
is_exception_saved = false
|
41
|
+
saved_name = nil
|
54
42
|
|
55
|
-
|
43
|
+
saved_e = nil
|
44
|
+
@streams.each do |name, stream|
|
45
|
+
begin
|
46
|
+
stream.send(*args)
|
47
|
+
rescue => saved_e
|
48
|
+
is_exception_saved = true
|
49
|
+
saved_name = name
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
if (is_exception_saved)
|
54
|
+
@streams.delete(saved_name)
|
55
|
+
raise "Caught exception on stream #{saved_name}, Error (#{saved_e.class}): #{saved_e.message}"
|
56
|
+
end
|
56
57
|
end
|
57
58
|
|
58
|
-
#
|
59
|
-
# ====Description:
|
60
|
-
# Flush all the configured streams.
|
61
|
-
#
|
62
|
-
def flush
|
63
|
-
@streams.each_value do |stream|
|
64
|
-
stream.flush()
|
65
|
-
end
|
66
|
-
end
|
67
|
-
|
68
|
-
#
|
69
|
-
# ====Description:
|
70
|
-
# Close all the configured streams.
|
71
|
-
#
|
72
|
-
def close
|
73
|
-
@streams.each_value do |stream|
|
74
|
-
stream.close()
|
75
|
-
end
|
76
|
-
end
|
77
59
|
end
|
78
60
|
end
|
@@ -0,0 +1,89 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require 'date'
|
4
|
+
|
5
|
+
module JobManager
|
6
|
+
|
7
|
+
#
|
8
|
+
# This class represents a stream whose only purpose is to forward
|
9
|
+
# messages on to a list of configured streams.
|
10
|
+
#
|
11
|
+
class TimestampedStream < IO
|
12
|
+
|
13
|
+
attr_reader :stream
|
14
|
+
#
|
15
|
+
# ====Description:
|
16
|
+
# This method creates an TeeStream instance initialized with the
|
17
|
+
# hash of streams passed in. When a message is written to this
|
18
|
+
# stream, it will be forwarded to all hash values passed in.
|
19
|
+
# ====Parameters:
|
20
|
+
# [streams]
|
21
|
+
# A hash table of stream name to stream.
|
22
|
+
#
|
23
|
+
def initialize(stream,
|
24
|
+
date_time_format = "%F_%T")
|
25
|
+
@stream = stream
|
26
|
+
@date_time_format = date_time_format
|
27
|
+
@is_start_of_line = true
|
28
|
+
end
|
29
|
+
|
30
|
+
#
|
31
|
+
# ====Description:
|
32
|
+
# This method forwards the string message to all configured
|
33
|
+
# streams. Note: This class is derived from IO, and thus only
|
34
|
+
# needs to implement the write method in order for all of the
|
35
|
+
# write-related methods (<<, print, puts, putc, etc) to work free
|
36
|
+
# of charge, as all of these methods call write.
|
37
|
+
# ====Parameters:
|
38
|
+
# [str]
|
39
|
+
# The string to be written.
|
40
|
+
# ====Returns:
|
41
|
+
# The length of the string.
|
42
|
+
#
|
43
|
+
def write(str)
|
44
|
+
if (str.length() == 0)
|
45
|
+
return 0
|
46
|
+
end
|
47
|
+
|
48
|
+
str = str.dup()
|
49
|
+
now = DateTime.now().strftime(@date_time_format)
|
50
|
+
|
51
|
+
# if string ends with a new line, strip it off and save it.
|
52
|
+
if (str.sub!(/\n\z/, ""))
|
53
|
+
ends_with_new_line = true
|
54
|
+
end
|
55
|
+
|
56
|
+
# replace all of the new lines with a new line followed by the
|
57
|
+
# current date/time.
|
58
|
+
str.gsub!(/\n/, "\n#{now} ")
|
59
|
+
|
60
|
+
if (@is_start_of_line)
|
61
|
+
str = "#{now} #{str}"
|
62
|
+
@is_start_of_line = false
|
63
|
+
end
|
64
|
+
|
65
|
+
if (ends_with_new_line)
|
66
|
+
str += "\n"
|
67
|
+
@is_start_of_line = true
|
68
|
+
end
|
69
|
+
|
70
|
+
@stream.write(str)
|
71
|
+
end
|
72
|
+
|
73
|
+
#
|
74
|
+
# ====Description:
|
75
|
+
# Flush all the configured streams.
|
76
|
+
#
|
77
|
+
def flush
|
78
|
+
@stream.flush()
|
79
|
+
end
|
80
|
+
|
81
|
+
#
|
82
|
+
# ====Description:
|
83
|
+
# Close all the configured streams.
|
84
|
+
#
|
85
|
+
def close
|
86
|
+
@stream.close()
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
@@ -30,4 +30,10 @@ command: "echo hello"
|
|
30
30
|
|
31
31
|
email_from_address: "sender_address@gmail.com"
|
32
32
|
|
33
|
-
email_to_address: "receiver_address@gmail.com"
|
33
|
+
email_to_address: "receiver_address@gmail.com"
|
34
|
+
|
35
|
+
rotate_job_log: false
|
36
|
+
|
37
|
+
job_log_prepend_date_time: true
|
38
|
+
|
39
|
+
job_log_prepend_date_time_format: '%F'
|
data/test/configs/{jobmanager_4_bad_job_logs_directory_2.yaml → jobmanager_5_bad_job_log_file.yaml}
RENAMED
File without changes
|
@@ -0,0 +1,15 @@
|
|
1
|
+
job_logs_directory: temp/logs
|
2
|
+
|
3
|
+
central_log_mode: file
|
4
|
+
|
5
|
+
central_log_file: temp/logs/jobmanager.log
|
6
|
+
|
7
|
+
rotate_job_log: false
|
8
|
+
|
9
|
+
email_condition: on_job_output_or_failure
|
10
|
+
|
11
|
+
email_settings_file: email_settings.rb
|
12
|
+
|
13
|
+
job_log_prepend_date_time: true
|
14
|
+
|
15
|
+
job_log_prepend_date_time_format: '%F'
|
data/test/test_config.rb
CHANGED
@@ -51,6 +51,9 @@ class ConfigTest < Test::Unit::TestCase
|
|
51
51
|
assert_equal("echo hello", config.command)
|
52
52
|
assert_equal("sender_address@gmail.com", config.email_from_address)
|
53
53
|
assert_equal("receiver_address@gmail.com", config.email_to_address)
|
54
|
+
assert_equal(false, config.rotate_job_log)
|
55
|
+
assert_equal(true, config.job_log_prepend_date_time)
|
56
|
+
assert_equal("%F", config.job_log_prepend_date_time_format)
|
54
57
|
|
55
58
|
result = 'Success'
|
56
59
|
host_name = "jackfruit"
|
@@ -77,11 +80,13 @@ class ConfigTest < Test::Unit::TestCase
|
|
77
80
|
assert_equal(nil, config.central_log_file)
|
78
81
|
assert_equal("jobmanager results for <%=job_name%> on <%=host_name%> : <%=result%>",
|
79
82
|
config.email_subject)
|
80
|
-
|
81
|
-
|
83
|
+
assert_equal(ENV['PATH'], config.command_path)
|
82
84
|
assert_equal(nil, config.timeout)
|
83
85
|
assert_equal(nil, config.email_to_address)
|
84
86
|
assert_equal(nil, config.email_from_address)
|
87
|
+
assert_equal(true, config.rotate_job_log)
|
88
|
+
assert_equal(false, config.job_log_prepend_date_time)
|
89
|
+
assert_equal("%F_%T", config.job_log_prepend_date_time_format)
|
85
90
|
end
|
86
91
|
|
87
92
|
def test_good_configs
|
@@ -101,7 +106,7 @@ class ConfigTest < Test::Unit::TestCase
|
|
101
106
|
confirm_bad_config_throws_error("bad_number_of_job_logs.yaml", msg)
|
102
107
|
|
103
108
|
msg = "error setting email_condition with value invalid: "
|
104
|
-
msg << "email_condition must be one of the following values: always, never, on_failure."
|
109
|
+
msg << "email_condition must be one of the following values: always, never, on_failure, on_job_output_or_failure."
|
105
110
|
confirm_bad_config_throws_error("bad_email_condition.yaml", msg)
|
106
111
|
|
107
112
|
msg = "JobManager::ApplicationConfig#validate_all_values error: "
|
@@ -140,25 +145,28 @@ class ConfigTest < Test::Unit::TestCase
|
|
140
145
|
|
141
146
|
# match the config specified in all_values.yaml
|
142
147
|
email_subject = "jobmanager results for <%=job_name%> on <%=host_name%> : <%=result%>"
|
143
|
-
args = [ "--job_name",
|
144
|
-
"--job_logs_directory",
|
145
|
-
"--email_condition",
|
146
|
-
"--central_log_mode",
|
147
|
-
"--central_log_file",
|
148
|
-
"--number_of_job_logs",
|
148
|
+
args = [ "--job_name", "ze_echoer",
|
149
|
+
"--job_logs_directory", "temp/logs",
|
150
|
+
"--email_condition", "always",
|
151
|
+
"--central_log_mode", "file",
|
152
|
+
"--central_log_file", "temp/logs/jobmanager.log",
|
153
|
+
"--number_of_job_logs", "4",
|
149
154
|
"--date_time_extension",
|
150
|
-
"--date_time_extension_format",
|
155
|
+
"--date_time_extension_format", "%F",
|
151
156
|
"--zip_rotated_log_file",
|
152
|
-
"--timeout",
|
153
|
-
"--email_settings_file",
|
154
|
-
"--email_from_address",
|
155
|
-
"--email_to_address",
|
156
|
-
"--command_path",
|
157
|
-
"--email_subject",
|
157
|
+
"--timeout", "1800",
|
158
|
+
"--email_settings_file", "email_settings.rb",
|
159
|
+
"--email_from_address", "sender_address@gmail.com",
|
160
|
+
"--email_to_address", "receiver_address@gmail.com",
|
161
|
+
"--command_path", "/usr/designingpatterns/bin",
|
162
|
+
"--email_subject", email_subject,
|
163
|
+
"--no-rotate_job_log",
|
164
|
+
"--job_log_prepend_date_time",
|
165
|
+
"--job_log_prepend_date_time_format", "%F",
|
158
166
|
"--debug",
|
159
167
|
"echo hello"
|
160
168
|
]
|
161
|
-
|
169
|
+
|
162
170
|
hash = JobManager::ApplicationOptionParser.parse(program_name, args)
|
163
171
|
config = JobManager::ApplicationConfig.load(ConfigToolkit::HashReader.new(hash))
|
164
172
|
confirm_all_values(config)
|
@@ -185,12 +193,15 @@ class ConfigTest < Test::Unit::TestCase
|
|
185
193
|
config_file = File.join(CONFIG_DIR, "incomplete_1.yaml")
|
186
194
|
yaml_reader = ConfigToolkit::YAMLReader.new(config_file)
|
187
195
|
|
188
|
-
args = [ "--job_name",
|
189
|
-
"--email_to_address",
|
190
|
-
"--central_log_file",
|
191
|
-
"--email_settings_file",
|
192
|
-
"--timeout",
|
196
|
+
args = [ "--job_name", "ze_echoer",
|
197
|
+
"--email_to_address", "receiver_address@gmail.com",
|
198
|
+
"--central_log_file", "temp/logs/jobmanager.log",
|
199
|
+
"--email_settings_file", "email_settings.rb",
|
200
|
+
"--timeout", "1800",
|
193
201
|
"--zip_rotated_log_file",
|
202
|
+
"--no-rotate_job_log",
|
203
|
+
"--job_log_prepend_date_time",
|
204
|
+
"--job_log_prepend_date_time_format", "%F",
|
194
205
|
"--debug",
|
195
206
|
"echo hello"
|
196
207
|
]
|