status-page 0.1.1
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.
- checksums.yaml +7 -0
- data/README.md +111 -0
- data/Rakefile +16 -0
- data/app/controllers/status_page/status_controller.rb +35 -0
- data/app/views/status_page/status/index.html.erb +71 -0
- data/config/routes.rb +3 -0
- data/lib/status-page.rb +9 -0
- data/lib/status-page/configuration.rb +33 -0
- data/lib/status-page/engine.rb +5 -0
- data/lib/status-page/monitor.rb +57 -0
- data/lib/status-page/services/base.rb +39 -0
- data/lib/status-page/services/cache.rb +24 -0
- data/lib/status-page/services/database.rb +14 -0
- data/lib/status-page/services/redis.rb +29 -0
- data/lib/status-page/services/resque.rb +15 -0
- data/lib/status-page/services/sidekiq.rb +56 -0
- data/lib/status-page/version.rb +5 -0
- metadata +78 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 5af2f060a08cd8558560479bcc2f1e051751c99d
|
4
|
+
data.tar.gz: 6eec97daa50456360a14693c331485b42cb69c40
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: a505245fe787bd9c95f98e80d79f2ccefb84ed4a4d2502789a73b7c8c8a4962d6d4dc6f0873de37550959d197b65fa86734bdbc3bbabd3ccc74582a01434533e
|
7
|
+
data.tar.gz: ef15709301c85a6f58362bd7428ddd7ffa3cb8e0d3683434c1f6a8805c5da0e1c581c7cedcc2fc9e535c3d98a2dc56c69c1ca48f80a0563ecc7da8b5ddc94705
|
data/README.md
ADDED
@@ -0,0 +1,111 @@
|
|
1
|
+
# status-page
|
2
|
+
|
3
|
+
[](http://badge.fury.io/rb/status-page) [](https://travis-ci.org/rails-engine/status-page) [](https://gemnasium.com/rails-engine/status-page) [](https://coveralls.io/r/rails-engine/status-page)
|
4
|
+
|
5
|
+
Mountable status page for your Rails application, to check (DB, Cache, Sidekiq, Redis, etc.).
|
6
|
+
|
7
|
+
Mounting this gem will add a '/status' route to your application, which can be used for health monitoring the application and its various services. The method will return an appropriate HTTP status as well as a JSON array representing the state of each service.
|
8
|
+
|
9
|
+
## Example
|
10
|
+
|
11
|
+
<img src="https://cloud.githubusercontent.com/assets/5518/14341727/c12ccdee-fcc6-11e5-8c25-00324d0e9baa.png" />
|
12
|
+
|
13
|
+
## Install
|
14
|
+
|
15
|
+
```ruby
|
16
|
+
# Gemfile
|
17
|
+
gem 'status-page'
|
18
|
+
```
|
19
|
+
|
20
|
+
Then run:
|
21
|
+
|
22
|
+
```bash
|
23
|
+
$ bundle install
|
24
|
+
```
|
25
|
+
|
26
|
+
```ruby
|
27
|
+
# config/routes.rb
|
28
|
+
mount StatusPage::Engine, at: '/'
|
29
|
+
```
|
30
|
+
|
31
|
+
## Supported service services
|
32
|
+
|
33
|
+
The following services are currently supported:
|
34
|
+
|
35
|
+
* DB
|
36
|
+
* Cache
|
37
|
+
* Redis
|
38
|
+
* Sidekiq
|
39
|
+
* Resque
|
40
|
+
|
41
|
+
## Configuration
|
42
|
+
|
43
|
+
### Adding services
|
44
|
+
|
45
|
+
By default, only the database check is enabled. You can add more service services by explicitly enabling them via an initializer:
|
46
|
+
|
47
|
+
```ruby
|
48
|
+
StatusPage.configure do
|
49
|
+
# Cache check status result 10 seconds
|
50
|
+
self.interval = 10
|
51
|
+
# Use service
|
52
|
+
self.use :database
|
53
|
+
self.use :cache
|
54
|
+
self.use :redis
|
55
|
+
self.use :sidekiq
|
56
|
+
end
|
57
|
+
```
|
58
|
+
|
59
|
+
### Adding a custom service
|
60
|
+
|
61
|
+
It's also possible to add custom health check services suited for your needs (of course, it's highly appreciated and encouraged if you'd contribute useful services to the project).
|
62
|
+
|
63
|
+
In order to add a custom service, you'd need to:
|
64
|
+
|
65
|
+
* Implement the `StatusPage::Services::Base` class and its `check!` method (a check is considered as failed if it raises an exception):
|
66
|
+
|
67
|
+
```ruby
|
68
|
+
class CustomService < StatusPage::Services::Base
|
69
|
+
def check!
|
70
|
+
raise 'Oh oh!'
|
71
|
+
end
|
72
|
+
end
|
73
|
+
```
|
74
|
+
* Add its class to the config:
|
75
|
+
|
76
|
+
```ruby
|
77
|
+
StatusPage.configure do
|
78
|
+
self.add_custom_service(CustomProvider)
|
79
|
+
end
|
80
|
+
```
|
81
|
+
|
82
|
+
### Adding a custom error callback
|
83
|
+
|
84
|
+
If you need to perform any additional error handling (for example, for additional error reporting), you can configure a custom error callback:
|
85
|
+
|
86
|
+
```ruby
|
87
|
+
StatusPage.configure do
|
88
|
+
self.error_callback = proc do |e|
|
89
|
+
logger.error "Health check failed with: #{e.message}"
|
90
|
+
|
91
|
+
Raven.capture_exception(e)
|
92
|
+
end
|
93
|
+
end
|
94
|
+
```
|
95
|
+
|
96
|
+
### Adding authentication credentials
|
97
|
+
|
98
|
+
By default, the `/status` endpoint is not authenticated and is available to any user. You can authenticate using HTTP Basic Auth by providing authentication credentials:
|
99
|
+
|
100
|
+
```ruby
|
101
|
+
StatusPage.configure do
|
102
|
+
self.basic_auth_credentials = {
|
103
|
+
username: 'SECRET_NAME',
|
104
|
+
password: 'Shhhhh!!!'
|
105
|
+
}
|
106
|
+
end
|
107
|
+
```
|
108
|
+
|
109
|
+
## License
|
110
|
+
|
111
|
+
The MIT License (MIT)
|
data/Rakefile
ADDED
@@ -0,0 +1,16 @@
|
|
1
|
+
#!/usr/bin/env rake
|
2
|
+
begin
|
3
|
+
require 'bundler/setup'
|
4
|
+
rescue LoadError
|
5
|
+
puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
|
6
|
+
end
|
7
|
+
|
8
|
+
require 'rspec/core/rake_task'
|
9
|
+
require 'rubocop/rake_task'
|
10
|
+
|
11
|
+
RSpec::Core::RakeTask.new('spec')
|
12
|
+
Bundler::GemHelper.install_tasks
|
13
|
+
|
14
|
+
task :default => :spec
|
15
|
+
|
16
|
+
RuboCop::RakeTask.new
|
@@ -0,0 +1,35 @@
|
|
1
|
+
module StatusPage
|
2
|
+
class StatusController < ActionController::Base
|
3
|
+
before_action :authenticate_with_basic_auth
|
4
|
+
|
5
|
+
def index
|
6
|
+
@statuses = statuses
|
7
|
+
|
8
|
+
respond_to do |format|
|
9
|
+
format.html
|
10
|
+
format.json {
|
11
|
+
render json: statuses
|
12
|
+
}
|
13
|
+
format.xml {
|
14
|
+
render xml: statuses
|
15
|
+
}
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
def statuses
|
22
|
+
return @statuses if defined? @statuses
|
23
|
+
@statuses = StatusPage.check(request: request)
|
24
|
+
end
|
25
|
+
|
26
|
+
def authenticate_with_basic_auth
|
27
|
+
return true unless StatusPage.config.basic_auth_credentials
|
28
|
+
|
29
|
+
credentials = StatusPage.config.basic_auth_credentials
|
30
|
+
authenticate_or_request_with_http_basic do |name, password|
|
31
|
+
name == credentials[:username] && password == credentials[:password]
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
|
2
|
+
<!DOCTYPE html>
|
3
|
+
<html>
|
4
|
+
<head>
|
5
|
+
<title>Status</title>
|
6
|
+
<meta charset="utf-8">
|
7
|
+
<meta name="viewport" content="width=device-width">
|
8
|
+
<style type="text/css" media="screen">
|
9
|
+
body {
|
10
|
+
line-height: 2rem;
|
11
|
+
font-size: 14px;
|
12
|
+
background-color: #f0f0f0;
|
13
|
+
margin: 0;
|
14
|
+
padding: 0;
|
15
|
+
color: #000;
|
16
|
+
text-align: center;
|
17
|
+
}
|
18
|
+
|
19
|
+
.container {
|
20
|
+
width: 960px;
|
21
|
+
margin: 20px auto;
|
22
|
+
text-align: left;
|
23
|
+
}
|
24
|
+
|
25
|
+
h1 {
|
26
|
+
font-weight: normal;
|
27
|
+
line-height: 2.8rem;
|
28
|
+
font-size: 30px;
|
29
|
+
letter-spacing: -1px;
|
30
|
+
text-align: center;
|
31
|
+
color: #333;
|
32
|
+
}
|
33
|
+
|
34
|
+
.container {
|
35
|
+
width: 960px;
|
36
|
+
margin:40px auto;
|
37
|
+
overflow: hidden;
|
38
|
+
}
|
39
|
+
|
40
|
+
.statuses {
|
41
|
+
background: #FFF;
|
42
|
+
width: 100%;
|
43
|
+
border-radius: 5px;
|
44
|
+
}
|
45
|
+
.statuses h1 { border-radius: 5px 5px 0 0; background: #f9f9f9; padding: 10px; border-bottom: 1px solid #eee;}
|
46
|
+
.statuses .status { font-size: 14px; border-bottom: 1px solid #eee; padding: 15px; }
|
47
|
+
.statuses .status:last-child { border-bottom: 0px; }
|
48
|
+
.statuses .name { font-size: 20px; margin-right: 20px; min-width: 100px; font-weight: bold; color: #555; }
|
49
|
+
.statuses .state { font-size: 14px; float: right; width: 80px; color: #45b81d; }
|
50
|
+
.statuses .message { color: #666; }
|
51
|
+
.statuses .timestamp { width: 130px; color: #999; }
|
52
|
+
.statuses .status-error .state { color: red; }
|
53
|
+
</style>
|
54
|
+
</head>
|
55
|
+
|
56
|
+
<body>
|
57
|
+
<div class="container">
|
58
|
+
<div class="statuses">
|
59
|
+
<h1>Status Page</h1>
|
60
|
+
<% @statuses[:results].each do |status| %>
|
61
|
+
<div class="status status-<%= status[:status].downcase %>">
|
62
|
+
<div class="status-heading">
|
63
|
+
<span class="name"><%= status[:name] %></span>
|
64
|
+
<span class="state"><%= status[:status] %></span>
|
65
|
+
</div>
|
66
|
+
<div class="message"><%= status[:message] %></div>
|
67
|
+
</div>
|
68
|
+
<% end %>
|
69
|
+
</div>
|
70
|
+
</div>
|
71
|
+
</body>
|
data/config/routes.rb
ADDED
data/lib/status-page.rb
ADDED
@@ -0,0 +1,33 @@
|
|
1
|
+
module StatusPage
|
2
|
+
class Configuration
|
3
|
+
attr_accessor :error_callback, :basic_auth_credentials, :interval
|
4
|
+
attr_reader :providers
|
5
|
+
|
6
|
+
def initialize
|
7
|
+
@providers = Set.new
|
8
|
+
@interval = 10
|
9
|
+
end
|
10
|
+
|
11
|
+
def use(service_name)
|
12
|
+
require "status-page/services/#{service_name}"
|
13
|
+
add_service("StatusPage::Services::#{service_name.capitalize}".constantize)
|
14
|
+
end
|
15
|
+
|
16
|
+
def add_custom_service(custom_service_class)
|
17
|
+
unless custom_service_class < StatusPage::Services::Base
|
18
|
+
raise ArgumentError.new 'custom provider class must implement '\
|
19
|
+
'StatusPage::Services::Base'
|
20
|
+
end
|
21
|
+
|
22
|
+
add_service(custom_service_class)
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
def add_service(provider_class)
|
28
|
+
(@providers ||= Set.new) << provider_class
|
29
|
+
|
30
|
+
provider_class
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
module StatusPage
|
2
|
+
STATUSES = {
|
3
|
+
ok: 'OK',
|
4
|
+
error: 'ERROR'
|
5
|
+
}.freeze
|
6
|
+
|
7
|
+
class << self
|
8
|
+
def config
|
9
|
+
return @config if defined?(@config)
|
10
|
+
@config = Configuration.new
|
11
|
+
@config
|
12
|
+
end
|
13
|
+
|
14
|
+
def configure(&block)
|
15
|
+
config.instance_exec(&block)
|
16
|
+
end
|
17
|
+
|
18
|
+
def check(request: nil)
|
19
|
+
if config.interval > 0
|
20
|
+
if @cached_status && @cached_status[:timestamp] >= (config.interval || 5).seconds.ago
|
21
|
+
return @cached_status
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
providers = config.providers || []
|
26
|
+
results = providers.map { |provider| provider_result(provider, request) }
|
27
|
+
|
28
|
+
@cached_status = {
|
29
|
+
results: results,
|
30
|
+
status: results.all? { |result| result[:status] == STATUSES[:ok] } ? :ok : :service_unavailable,
|
31
|
+
timestamp: Time.now
|
32
|
+
}
|
33
|
+
@cached_status
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
def provider_result(provider, request)
|
39
|
+
monitor = provider.new(request: request)
|
40
|
+
monitor.check!
|
41
|
+
|
42
|
+
{
|
43
|
+
name: provider.service_name,
|
44
|
+
message: '',
|
45
|
+
status: STATUSES[:ok]
|
46
|
+
}
|
47
|
+
rescue => e
|
48
|
+
config.error_callback.call(e) if config.error_callback
|
49
|
+
|
50
|
+
{
|
51
|
+
name: provider.service_name,
|
52
|
+
message: e.message,
|
53
|
+
status: STATUSES[:error]
|
54
|
+
}
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
module StatusPage
|
2
|
+
module Services
|
3
|
+
class Base
|
4
|
+
attr_reader :request
|
5
|
+
cattr_accessor :config
|
6
|
+
|
7
|
+
def self.service_name
|
8
|
+
@name ||= name.demodulize
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.configure
|
12
|
+
return unless configurable?
|
13
|
+
|
14
|
+
self.config ||= config_class.new
|
15
|
+
|
16
|
+
yield self.config if block_given?
|
17
|
+
end
|
18
|
+
|
19
|
+
def initialize(request: nil)
|
20
|
+
@request = request
|
21
|
+
|
22
|
+
self.class.configure
|
23
|
+
end
|
24
|
+
|
25
|
+
# @abstract
|
26
|
+
def check!
|
27
|
+
raise NotImplementedError
|
28
|
+
end
|
29
|
+
|
30
|
+
def self.configurable?
|
31
|
+
config_class
|
32
|
+
end
|
33
|
+
|
34
|
+
# @abstract
|
35
|
+
def self.config_class
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
module StatusPage
|
2
|
+
module Services
|
3
|
+
class CacheException < StandardError; end
|
4
|
+
|
5
|
+
class Cache < Base
|
6
|
+
def check!
|
7
|
+
time = Time.now.to_s
|
8
|
+
|
9
|
+
Rails.cache.write(key, time)
|
10
|
+
fetched = Rails.cache.read(key)
|
11
|
+
|
12
|
+
raise "different values (now: #{time}, fetched: #{fetched})" if fetched != time
|
13
|
+
rescue Exception => e
|
14
|
+
raise CacheException.new(e.message)
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
def key
|
20
|
+
@key ||= ['status-cache', request.try(:remote_ip)].join(':')
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
module StatusPage
|
2
|
+
module Services
|
3
|
+
class DatabaseException < StandardError; end
|
4
|
+
|
5
|
+
class Database < Base
|
6
|
+
def check!
|
7
|
+
# Check connection to the DB:
|
8
|
+
ActiveRecord::Migrator.current_version
|
9
|
+
rescue Exception => e
|
10
|
+
raise DatabaseException.new(e.message)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
require 'redis/namespace'
|
2
|
+
|
3
|
+
module StatusPage
|
4
|
+
module Services
|
5
|
+
class RedisException < StandardError; end
|
6
|
+
|
7
|
+
class Redis < Base
|
8
|
+
def check!
|
9
|
+
time = Time.now.to_s(:db)
|
10
|
+
|
11
|
+
redis = ::Redis.new
|
12
|
+
redis.set(key, time)
|
13
|
+
fetched = redis.get(key)
|
14
|
+
|
15
|
+
raise "different values (now: #{time}, fetched: #{fetched})" if fetched != time
|
16
|
+
rescue Exception => e
|
17
|
+
raise RedisException.new(e.message)
|
18
|
+
ensure
|
19
|
+
redis.client.disconnect
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def key
|
25
|
+
@key ||= ['status-redis', request.try(:remote_ip)].join(':')
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
require 'sidekiq/api'
|
2
|
+
|
3
|
+
module StatusPage
|
4
|
+
module Services
|
5
|
+
class SidekiqException < StandardError; end
|
6
|
+
|
7
|
+
class Sidekiq < Base
|
8
|
+
class Configuration
|
9
|
+
DEFAULT_LATENCY_TIMEOUT = 30
|
10
|
+
|
11
|
+
attr_accessor :latency
|
12
|
+
|
13
|
+
def initialize
|
14
|
+
@latency = DEFAULT_LATENCY_TIMEOUT
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def check!
|
19
|
+
check_workers!
|
20
|
+
check_latency!
|
21
|
+
check_redis!
|
22
|
+
rescue Exception => e
|
23
|
+
raise SidekiqException.new(e.message)
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
class << self
|
29
|
+
private
|
30
|
+
|
31
|
+
def config_class
|
32
|
+
Configuration
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def check_workers!
|
37
|
+
sidekiq_stats = ::Sidekiq::Stats.new
|
38
|
+
if sidekiq_stats.processes_size == 0
|
39
|
+
raise "Sidekiq alive processes is 0."
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def check_latency!
|
44
|
+
latency = ::Sidekiq::Queue.new.latency
|
45
|
+
|
46
|
+
return unless latency > config.latency
|
47
|
+
|
48
|
+
raise "latency #{latency} is greater than #{config.latency}"
|
49
|
+
end
|
50
|
+
|
51
|
+
def check_redis!
|
52
|
+
::Sidekiq.redis(&:info)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
metadata
ADDED
@@ -0,0 +1,78 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: status-page
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Leonid Beder
|
8
|
+
- Jason Lee
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2016-04-07 00:00:00.000000000 Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: rails
|
16
|
+
requirement: !ruby/object:Gem::Requirement
|
17
|
+
requirements:
|
18
|
+
- - ">="
|
19
|
+
- !ruby/object:Gem::Version
|
20
|
+
version: '4.2'
|
21
|
+
type: :runtime
|
22
|
+
prerelease: false
|
23
|
+
version_requirements: !ruby/object:Gem::Requirement
|
24
|
+
requirements:
|
25
|
+
- - ">="
|
26
|
+
- !ruby/object:Gem::Version
|
27
|
+
version: '4.2'
|
28
|
+
description: Health monitoring Rails plug-in, which checks various services (db, cache,
|
29
|
+
sidekiq, redis, etc.).
|
30
|
+
email:
|
31
|
+
- leonid.beder@gmail.com
|
32
|
+
- huacnlee@gmail.com
|
33
|
+
executables: []
|
34
|
+
extensions: []
|
35
|
+
extra_rdoc_files: []
|
36
|
+
files:
|
37
|
+
- README.md
|
38
|
+
- Rakefile
|
39
|
+
- app/controllers/status_page/status_controller.rb
|
40
|
+
- app/views/status_page/status/index.html.erb
|
41
|
+
- config/routes.rb
|
42
|
+
- lib/status-page.rb
|
43
|
+
- lib/status-page/configuration.rb
|
44
|
+
- lib/status-page/engine.rb
|
45
|
+
- lib/status-page/monitor.rb
|
46
|
+
- lib/status-page/services/base.rb
|
47
|
+
- lib/status-page/services/cache.rb
|
48
|
+
- lib/status-page/services/database.rb
|
49
|
+
- lib/status-page/services/redis.rb
|
50
|
+
- lib/status-page/services/resque.rb
|
51
|
+
- lib/status-page/services/sidekiq.rb
|
52
|
+
- lib/status-page/version.rb
|
53
|
+
homepage: https://github.com/rails-engine/status-page
|
54
|
+
licenses:
|
55
|
+
- MIT
|
56
|
+
metadata: {}
|
57
|
+
post_install_message:
|
58
|
+
rdoc_options: []
|
59
|
+
require_paths:
|
60
|
+
- lib
|
61
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
62
|
+
requirements:
|
63
|
+
- - ">="
|
64
|
+
- !ruby/object:Gem::Version
|
65
|
+
version: '0'
|
66
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
67
|
+
requirements:
|
68
|
+
- - ">="
|
69
|
+
- !ruby/object:Gem::Version
|
70
|
+
version: '0'
|
71
|
+
requirements: []
|
72
|
+
rubyforge_project:
|
73
|
+
rubygems_version: 2.6.2
|
74
|
+
signing_key:
|
75
|
+
specification_version: 4
|
76
|
+
summary: Health monitoring Rails plug-in, which checks various services (db, cache,
|
77
|
+
sidekiq, redis, etc.)
|
78
|
+
test_files: []
|