tn_s3_file_uploader 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,15 @@
1
+ ---
2
+ !binary "U0hBMQ==":
3
+ metadata.gz: !binary |-
4
+ MGRmOTk0YjUyZjhhMTE5MjU5MjNhZjBiNmFmYTAwYWI2YThmMTVjYw==
5
+ data.tar.gz: !binary |-
6
+ NmM5MWNhYzIyZDlhNGIzZjRjOWM0ZmM1MjViZGE4Zjg1N2Q5ODc5Zg==
7
+ SHA512:
8
+ metadata.gz: !binary |-
9
+ MDhlMTJmNDFkZDU2YWNjZGI4M2JhZmYwZWRmOWMwZjc2ZmM4MjQ5M2U2M2I0
10
+ ZDczMDFiMGFhYzAzYTdmN2M2MTMyODRmNGJmZTRkYmNlZjNiNGFiZWRlMTQ1
11
+ NTdmNTZlYzE1NTc3MjJhMmE5MjFiZTQ3MDBmMjNjM2FmODQyMDg=
12
+ data.tar.gz: !binary |-
13
+ MzhmN2EzMWE3ZGZjOTM3Mzc2YTliNWZlYjZjMmVlN2MxZTNmMDJhYTEzZTdl
14
+ MTkwYjZhYTBmOWZjMTMwYzQ5NjlmZDc4Y2ExM2FiNmM2ODI5N2JkYzliZWYw
15
+ NmU4Yjg3YzI3ZTllNzRkZTFlMmM0YWJmNTZmYTBmNTUzYWQ4OGE=
data/LICENSE.txt ADDED
@@ -0,0 +1,13 @@
1
+ Copyright 2014 Telenav, Inc.
2
+
3
+ Licensed under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License.
5
+
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 distributed
11
+ under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
12
+ either express or implied. See the License for the specific language governing permissions and limitations
13
+ under the License.
data/README.md ADDED
@@ -0,0 +1,23 @@
1
+ tn_s3_file_uploader
2
+ ===================
3
+
4
+ Introduction
5
+ ------------
6
+ tn_s3_file_uploader is an Amazon S3 file uploader that can build destination folder structures based on the timestamp and
7
+ a number of other accepted substitutions.
8
+
9
+ Getting started
10
+ ---------------
11
+ Visit the [Getting started wiki page](https://github.com/ThinkNear/tn_s3_file_uploader/wiki/Getting-started)
12
+
13
+ License
14
+ -------
15
+ See the `LICENSE.txt` file for license information.
16
+
17
+ Contribute
18
+ ----------
19
+ Visit the [Contribute wiki page](https://github.com/ThinkNear/tn_s3_file_uploader/wiki/Contribute)
20
+
21
+ Questions? Problems? New Requests?
22
+ ----------------------------------
23
+ Visit the [Troubleshooting wiki page](https://github.com/ThinkNear/tn_s3_file_uploader/wiki/Troubleshooting)
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env ruby
2
+ require 'tn_s3_file_uploader'
3
+
4
+ # Executable - usage tn_s3_file_uploader --input-file-pattern='<<pattern matching file to be uploaded>>'
5
+ # --s3-output-pattern=<<S3 destination folder>>
6
+ #
7
+ # e.g. tn_s3_file_uploader --input-file-pattern='/etc/sonarqube/logs/*.log'
8
+ # --s3-output-pattern=bucket/sonarqube/%{file-extension}/y=%Y/m=%m/d=%d/h=%H/%{file-name}.%{file-extension}
9
+ options = TnS3FileUploader::CliParser.new.parse_cmd_line(ARGV)
10
+ TnS3FileUploader::Runner.new(options).run
11
+
@@ -0,0 +1,89 @@
1
+ require 'optparse'
2
+
3
+ module TnS3FileUploader
4
+
5
+ class CliParser
6
+
7
+ # Parses the CLI parameters and returns them in a hash
8
+ # Parameters 'host-unique-id', 'log-file-pattern', 's3-dest-folder' and 'partition-pattern' are mandatory and
9
+ # a missing argument exception will be raised if any of them is not provided
10
+ def parse_cmd_line(args)
11
+ options = {}
12
+ opts = OptionParser.new do |opts|
13
+ opts.banner = "Usage: tn_s3_file_uploader.rb [options]"
14
+
15
+ opts.separator ""
16
+ opts.separator "Specific options:"
17
+
18
+ opts.on("-s", "--input-file-pattern INPUT_FILE_PATTERN", "The file pattern to match the source log file") do |file_pattern|
19
+ options[:input_file_pattern] = file_pattern
20
+ end
21
+
22
+ opts.on("-o", "--s3-output-pattern S3_OUTPUT_PATTERN", "The S3 destination pattern name."\
23
+ "It accepts macros for building the resulting filename and folder structure. See documentation for details") do |s3_output_pattern|
24
+ options[:s3_output_pattern] = s3_output_pattern
25
+ end
26
+
27
+ options[:file_timestamp_resolution] = 300
28
+ opts.on("--file-timestamp-resolution RES", Integer, "The resolution of the destination filename in seconds (positive non-zero integer)") do |file_timestamp_resolution|
29
+ if valid_seconds?(file_timestamp_resolution)
30
+ options[:file_timestamp_resolution] = file_timestamp_resolution
31
+ else
32
+ puts "Warning: negative seconds value given: #{file_timestamp_resolution}, defaulting to 300 (5 minutes)"
33
+ end
34
+ end
35
+
36
+ opts.on("--honeybadger-api-key API-KEY", "API key for optional honeybadger error reporting support") do |honeybadger_api_key|
37
+ options[:honeybadger_api_key] = honeybadger_api_key
38
+ end
39
+
40
+ opts.on("--aws-access-key-id AWS-ACCESS-KEY-ID", "Provide the AWS access key id.") do |aws_access_key_id|
41
+ options[:aws_access_key_id] = aws_access_key_id
42
+ end
43
+
44
+ opts.on("--aws-secret-access-key AWS-SECRET-ACCESS-KEY", "Provide the AWS secret access key") do |aws_secret_access_key|
45
+ options[:aws_secret_access_key] = aws_secret_access_key
46
+ end
47
+
48
+ opts.on("-v", "--verbose", "Display verbose output") do |v|
49
+ options[:verbose] = !v.nil?
50
+ end
51
+
52
+ opts.separator ""
53
+
54
+ opts.on_tail("-h", "--help", "Show this message") do
55
+ puts opts
56
+ exit!
57
+ end
58
+ end
59
+
60
+ opts.parse! args
61
+
62
+ check_mandatory options
63
+
64
+ options
65
+ end
66
+
67
+ private
68
+
69
+ def check_mandatory(options)
70
+ mandatory_arguments = [:input_file_pattern, :s3_output_pattern]
71
+
72
+ missing_arguments = []
73
+ mandatory_arguments.each do |arg|
74
+ missing_arguments << arg.to_s.gsub!('_', '-') if options[arg].nil?
75
+ end
76
+
77
+ unless missing_arguments.empty?
78
+ raise OptionParser::MissingArgument,
79
+ "The following mandatory options are missing: #{ missing_arguments.join(', ') }"
80
+ end
81
+ end
82
+
83
+ def valid_seconds?(seconds)
84
+ seconds > 0
85
+ end
86
+
87
+ end
88
+
89
+ end
@@ -0,0 +1,35 @@
1
+ require 'singleton'
2
+ require 'tn_s3_file_uploader/error_reporting/log_error_reporter'
3
+
4
+ module TnS3FileUploader
5
+
6
+ # Singleton that instruments all error reporters.
7
+ # Register your error reporter by calling `ErrorReportManager.instance.register_error_reporter`
8
+ # error reporters provided have to specify a method with name `report_error`
9
+ class ErrorReportManager
10
+ include Singleton
11
+
12
+ def initialize
13
+ @error_reporters = []
14
+ end
15
+
16
+ def register_error_reporter(error_reporter)
17
+ unless error_reporter.respond_to?(:report_error)
18
+ raise ArgumentError, 'Provided error_reporter instance does not support the report_error method'
19
+ end
20
+
21
+ @error_reporters << error_reporter
22
+ end
23
+
24
+ def count_error_reporters
25
+ @error_reporters.count
26
+ end
27
+
28
+ def report_error(exception, options = {})
29
+ @error_reporters.each do |error_reporter|
30
+ error_reporter.report_error(exception, options)
31
+ end
32
+ end
33
+ end
34
+
35
+ end
@@ -0,0 +1,20 @@
1
+ require 'honeybadger'
2
+
3
+ module TnS3FileUploader
4
+
5
+ # Error reporter that uses Honeybadger service to report errors
6
+ class HoneybadgerErrorReporter
7
+
8
+ # Configure honeybadger with provided api-key. Assumes api-key is not null
9
+ def initialize(api_key)
10
+ Honeybadger.configure do |config|
11
+ config.api_key = api_key
12
+ end
13
+ end
14
+
15
+ def report_error(exception, options = {})
16
+ Honeybadger.notify(exception, options)
17
+ end
18
+ end
19
+
20
+ end
@@ -0,0 +1,17 @@
1
+ module TnS3FileUploader
2
+
3
+ # Simple error reporter that logs exception message and backtrace to stdout
4
+ class LogErrorReporter
5
+
6
+ def initialize(output)
7
+ @output = output
8
+ end
9
+
10
+ def report_error(exception, options ={} )
11
+ @output.puts options
12
+ @output.puts exception.message
13
+ @output.puts exception.backtrace.join("\n")
14
+ end
15
+ end
16
+
17
+ end
@@ -0,0 +1,96 @@
1
+ require 'rubygems'
2
+
3
+ module TnS3FileUploader
4
+
5
+ # Examples
6
+ # partition = y=2014/m=06/d=18/h=18
7
+ # minute_partition = 45
8
+ # file_timestamp = 20140618184502
9
+ # date = Wed Jun 18 18:50:02 UTC 2014
10
+
11
+ # [ec2-user@ip-10-185-180-243 tmp]$ ./time.sh
12
+ # partition = y=2014/m=06/d=18/h=18
13
+ # minute_partition = 55
14
+ # file_timestamp = 20140618185540
15
+ # date = Wed Jun 18 19:03:40 UTC 2014
16
+ class FilePathGenerator
17
+
18
+ def initialize(time, options)
19
+ #Find the last rotation window
20
+ @options = options
21
+ @time = previous_rotation_window(time)
22
+ end
23
+
24
+
25
+ # Makes datetime and macro substitutions for input file 'file', based on the s3_output_pattern option
26
+ # Assumes input file 'file' and s3_output_pattern option are both valid.
27
+ # This method removes the bucket (everything until the first '/', including the '/') from the s3_output_pattern
28
+ # while applying the datetime/macro/substitutions
29
+ def dest_full_path_for(file)
30
+
31
+ output_file_pattern = remove_bucket(@options[:s3_output_pattern])
32
+
33
+ # Time#strftime is removing '%' characters on our macros. Our macro substitution must run first
34
+ subs = build_substitutions(file)
35
+ replace_macros!(output_file_pattern, subs)
36
+
37
+ substitute_datetime_macros(output_file_pattern)
38
+ end
39
+
40
+ private
41
+
42
+ def remove_bucket(output_file_pattern)
43
+ output_file_pattern.split('/')[1..-1].join('/')
44
+ end
45
+
46
+ # Makes the datetime substitutions on the give s3_output_pattern option
47
+ # For example:
48
+ # Given s3_output_pattern y=%Y/m=%m/d=%d/h=%H
49
+ # and time: Thu Jun 12 23:57:49 UTC 2014
50
+ # it will produce the following folder structure: y=2014/m=06/d=12/h=23
51
+ def substitute_datetime_macros(output_pattern)
52
+ @time.strftime(output_pattern)
53
+ end
54
+
55
+ # Generates rounded off timestamp based on rotation_seconds
56
+ def generate_file_timestamp
57
+ @time.strftime('%Y%m%d%H%M%S')
58
+ end
59
+
60
+ def replace_macros!(output_file_pattern, subs)
61
+ subs.each do |macro, sub|
62
+ output_file_pattern.gsub!(macro, sub)
63
+ end
64
+ end
65
+
66
+ def build_substitutions(file)
67
+ file_components = file.split('/').last.split('.')
68
+
69
+ if file_components.size == 1
70
+ file_name = file_components[0]
71
+ file_extension = ''
72
+ else
73
+ file_name = file_components[0..-2].join('.')
74
+ file_extension = file_components.last
75
+ end
76
+
77
+ ip_address = IPSocket.getaddress(Socket.gethostname).gsub('.', '-')
78
+ file_timestamp = generate_file_timestamp
79
+
80
+ {
81
+ '%{file-name}' => file_name,
82
+ '%{file-timestamp}' => file_timestamp,
83
+ '%{file-extension}' => file_extension,
84
+ '%{ip-address}' => ip_address
85
+ }
86
+ end
87
+
88
+ def previous_rotation_window(time)
89
+ rotation_seconds = @options[:file_timestamp_resolution]
90
+ t = time - rotation_seconds
91
+ floored_seconds = (t.to_f / rotation_seconds).floor * rotation_seconds
92
+ Time.at(floored_seconds).utc
93
+ end
94
+ end
95
+
96
+ end
@@ -0,0 +1,64 @@
1
+ require 'rubygems'
2
+ require 'tn_s3_file_uploader/s3'
3
+ require 'tn_s3_file_uploader/file_path_generator'
4
+
5
+ module TnS3FileUploader
6
+
7
+ class LogUploader
8
+
9
+ # Initialisation block
10
+ # Params:
11
+ # ::s3:: - S3 wrapper
12
+ def initialize(s3)
13
+ raise ArgumentError, "s3 client cannot be nil" if s3 == nil
14
+ @s3 = s3
15
+ end
16
+
17
+ # Uploads all (log) files that options[:input_file_pattern] matches to an S3 location
18
+ # based on the value of options[:s3_output_pattern]
19
+ # options[:input_file_pattern] should match at least one local file
20
+ # options[:s3_output_pattern] should contain the bucket, folder and destination filename
21
+ def upload_log_files(options)
22
+ raise ArgumentError, 's3_output_pattern cannot be empty' if blank?(options[:s3_output_pattern])
23
+ bucket = check_bucket_dest_path(options[:s3_output_pattern])
24
+ log_files = check_log_file(options[:input_file_pattern])
25
+
26
+ now = Time.now.utc
27
+ file_path_generator = FilePathGenerator.new(now, options)
28
+
29
+ log_files.each do |log_file|
30
+ destination_full_path = file_path_generator.dest_full_path_for(log_file)
31
+
32
+ puts "Found log file #{ log_file }, formatting file name for upload to S3 bucket #{ bucket } into folder #{ destination_full_path }"
33
+
34
+ # Note no leading or trailing slashes - this will break the upload to S3 (see our s3.rb)
35
+ @s3.upload_file(log_file, bucket, destination_full_path)
36
+ end
37
+
38
+ end
39
+
40
+ private
41
+ def check_log_file(log_file_pattern)
42
+ raise ArgumentError, 'log file pattern cannot be nil' if log_file_pattern == nil
43
+
44
+ last_folder_separator = log_file_pattern.rindex('.')
45
+ raise ArgumentError, "#{ log_file_pattern } is not a valid path. It lacks a file extension." if last_folder_separator == nil
46
+
47
+ files = Dir[log_file_pattern].entries
48
+ raise ArgumentError, "#{ log_file_pattern } did not match any files." if files.empty?
49
+
50
+ files
51
+ end
52
+
53
+ def check_bucket_dest_path(bucket_dest_path)
54
+ path_components = bucket_dest_path.split('/')
55
+ raise ArgumentError, "Bucket destination folder #{ bucket_dest_path } must have at least two path components, e.g. my/path." unless path_components.size > 1
56
+ path_components.first
57
+ end
58
+
59
+ def blank?(str)
60
+ str.nil? || str == ""
61
+ end
62
+ end
63
+
64
+ end
@@ -0,0 +1,64 @@
1
+ require 'rubygems'
2
+ require 'aws-sdk'
3
+ require 'honeybadger'
4
+ require 'tn_s3_file_uploader/log_uploader'
5
+
6
+ module TnS3FileUploader
7
+ class Runner
8
+
9
+ def initialize(options)
10
+ @options = options
11
+ @error_report_manager = ErrorReportManager.instance
12
+ end
13
+
14
+ def run
15
+ add_log_error_reporter
16
+ add_honeybadger
17
+ puts "Running TnS3FileUploader..." if @options[:verbose]
18
+
19
+ upload
20
+ rescue Exception => e
21
+ @error_report_manager.report_error(e, { :options => @options } )
22
+ end
23
+
24
+ private
25
+
26
+ def upload
27
+ if @options[:verbose]
28
+ puts "Using:"
29
+ puts "log file pattern = #{ @options[:input_file_pattern] }"
30
+ puts "s3 dest folder = #{ @options[:s3_output_pattern] }"
31
+ puts "file timestamp resolution = #{ options[:file_timestamp_resolution] }"
32
+ end
33
+
34
+ s3_client = create_s3_client
35
+ s3 = S3.new(s3_client)
36
+
37
+ log_uploader = LogUploader.new(s3)
38
+ log_uploader.upload_log_files(@options)
39
+ end
40
+
41
+ def create_s3_client
42
+ if @options[:aws_access_key_id].nil? && @options[:aws_secret_access_key].nil?
43
+ AWS::S3.new
44
+ else
45
+ AWS::S3.new(
46
+ :access_key_id => @options[:aws_access_key_id],
47
+ :secret_access_key => @options[:aws_secret_access_key]
48
+ )
49
+ end
50
+ end
51
+
52
+ def add_log_error_reporter
53
+ @error_report_manager.register_error_reporter(LogErrorReporter.new(STDOUT))
54
+ end
55
+
56
+ def add_honeybadger
57
+ unless @options[:honeybadger_api_key].nil?
58
+ honeybadger_error_reporter = HoneybadgerErrorReporter.new(@options[:honeybadger_api_key])
59
+ @error_report_manager.register_error_reporter(honeybadger_error_reporter)
60
+ end
61
+ end
62
+
63
+ end
64
+ end
@@ -0,0 +1,55 @@
1
+ require 'rubygems'
2
+ gem 'aws-sdk'
3
+ require 'aws-sdk'
4
+ require 'honeybadger'
5
+ require 'tn_s3_file_uploader/file_path_generator'
6
+ module TnS3FileUploader
7
+
8
+ class S3
9
+
10
+ MAX_RETRIES = 2
11
+
12
+ def initialize(s3_client)
13
+ @s3_client = s3_client
14
+ end
15
+
16
+ # File must be fully qualified
17
+ # bucket is just string name, no slashes
18
+ # dest_path is fully qualified path to file on S3 including folders - NO leading or trailing slashes or
19
+ # it won't work!
20
+ def upload_file(file, bucket, dest_path)
21
+ raise ArgumentError, "file cannot be nil" if file == nil
22
+ raise ArgumentError, "bucket cannot be nil" if bucket == nil
23
+ raise ArgumentError, "dest_path cannot be nil" if dest_path == nil
24
+
25
+ file_path = Pathname.new(file)
26
+ raise ArgumentError, "#{file} is not a valid file" unless file_path.exist? && file_path.file?
27
+
28
+ upload(bucket, dest_path, file)
29
+ end
30
+
31
+ private
32
+ def upload(bucket, dest_full_path, file, retry_count = 0)
33
+ begin
34
+ s3_bucket = @s3_client.buckets[bucket]
35
+ s3_file_path = s3_bucket.objects[dest_full_path]
36
+
37
+ puts "Uploading file #{file} to S3 bucket #{bucket} and path #{dest_full_path}"
38
+
39
+ s3_file_path.write(File.open(file, 'rb'))
40
+ rescue StandardError, Timeout::Error => e
41
+ if retry_count < MAX_RETRIES
42
+ #This fixes a bug where the credentials may have rotated on the EC2 instance but the old values
43
+ #are still cached
44
+ sleep 1
45
+ @s3_client.config.credential_provider.refresh
46
+ upload(bucket, dest_full_path, file, retry_count + 1)
47
+ else
48
+ raise e
49
+ end
50
+ end
51
+ end
52
+
53
+ end
54
+
55
+ end
@@ -0,0 +1,3 @@
1
+ module TnS3FileUploader
2
+ VERSION = '0.1.0'
3
+ end
@@ -0,0 +1,9 @@
1
+ require 'tn_s3_file_uploader/runner'
2
+ require 'tn_s3_file_uploader/cli_parser'
3
+ require 'tn_s3_file_uploader/file_path_generator'
4
+ require 'tn_s3_file_uploader/log_uploader'
5
+ require 'tn_s3_file_uploader/s3'
6
+
7
+ require 'tn_s3_file_uploader/error_reporting/error_report_manager'
8
+ require 'tn_s3_file_uploader/error_reporting/honeybadger_error_reporter'
9
+ require 'tn_s3_file_uploader/error_reporting/log_error_reporter'
metadata ADDED
@@ -0,0 +1,86 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tn_s3_file_uploader
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Thinknear.com
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2014-06-25 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: honeybadger
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ~>
18
+ - !ruby/object:Gem::Version
19
+ version: '1.15'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ~>
25
+ - !ruby/object:Gem::Version
26
+ version: '1.15'
27
+ - !ruby/object:Gem::Dependency
28
+ name: aws-sdk
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ~>
32
+ - !ruby/object:Gem::Version
33
+ version: '1.35'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ~>
39
+ - !ruby/object:Gem::Version
40
+ version: '1.35'
41
+ description: S3 file uploader that can build folder structures based on timestamp.
42
+ Typically used in conjunction with Unix's logrotate.
43
+ email: opensource@thinknear.com
44
+ executables:
45
+ - tn_s3_file_uploader
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - LICENSE.txt
50
+ - README.md
51
+ - bin/tn_s3_file_uploader
52
+ - lib/tn_s3_file_uploader.rb
53
+ - lib/tn_s3_file_uploader/cli_parser.rb
54
+ - lib/tn_s3_file_uploader/error_reporting/error_report_manager.rb
55
+ - lib/tn_s3_file_uploader/error_reporting/honeybadger_error_reporter.rb
56
+ - lib/tn_s3_file_uploader/error_reporting/log_error_reporter.rb
57
+ - lib/tn_s3_file_uploader/file_path_generator.rb
58
+ - lib/tn_s3_file_uploader/log_uploader.rb
59
+ - lib/tn_s3_file_uploader/runner.rb
60
+ - lib/tn_s3_file_uploader/s3.rb
61
+ - lib/tn_s3_file_uploader/version.rb
62
+ homepage: http://www.thinknear.com
63
+ licenses:
64
+ - Copyright (c) ThinkNear 2014, Licensed under APLv2.0
65
+ metadata: {}
66
+ post_install_message:
67
+ rdoc_options: []
68
+ require_paths:
69
+ - lib
70
+ required_ruby_version: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ! '>='
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ required_rubygems_version: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ! '>='
78
+ - !ruby/object:Gem::Version
79
+ version: '0'
80
+ requirements: []
81
+ rubyforge_project:
82
+ rubygems_version: 2.2.2
83
+ signing_key:
84
+ specification_version: 4
85
+ summary: S3 file uploader
86
+ test_files: []