app_status 0.1.1 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
data/README.md CHANGED
@@ -3,6 +3,39 @@
3
3
  AppStatus is a Rails engine which makes it easy to expose application status
4
4
  data in a way easily consumed by Nagios or other monitoring packages.
5
5
 
6
+ ## Why?
7
+
8
+ Defining health checks outside of your application (like in Nagios)
9
+ has a few different problems.
10
+
11
+ 1. The people who maintain nagios aren't necessarily
12
+ the same people who maintain the application.
13
+ 1. Keeping the 2 systems in sync can be non-trivial with a fast-changing
14
+ application.
15
+ 1. Failing to monitor new features, or monitoring the wrong things, leads
16
+ to a false sense of security.
17
+
18
+ Instead, app_status lets you define your health checks right in the application
19
+ itself and expose the results as a JSON service which is easy for Nagios
20
+ to consume.
21
+
22
+ The benefits basically come down to 1 major thing: Nagios doesn't need to know
23
+ anything about your application. All Nagios needs is a 'healthy/not healthy'
24
+ status report.
25
+
26
+ This is good because:
27
+
28
+ 1. As your app's feature set changes, you can deploy updated health checks
29
+ at the same time. No need for coordinated updates between the app and
30
+ the monitoring system.
31
+ 1. Credentials for external services (like databases) can stay with your
32
+ app. Nagios doesn't need them.
33
+ 1. You don't need nrpe to do local process checks. Your application can do
34
+ them for itself.
35
+ 1. Your health checks can be testable methods just like all your other code.
36
+ 1. You don't need to duplicate complex queries & other business logic over
37
+ to Nagios.
38
+
6
39
  ## Installation
7
40
 
8
41
  ### `Gemfile`
@@ -13,8 +46,6 @@ gem 'app_status'
13
46
 
14
47
  ### `config/routes.rb`
15
48
 
16
- Wire it up.
17
-
18
49
  ```ruby
19
50
  mount AppStatus::Engine, at: "/status"
20
51
  ```
@@ -30,19 +61,32 @@ This exposes the following URLs
30
61
 
31
62
  This is where you set up the checks which you want to be run when
32
63
  someone hits the URL above. Set up some calls which evaluate the health
33
- of your application and call `add` for each one.
64
+ of your application and call `add_check` for each one.
65
+
66
+ `add_check` expects a service name, plus a block to be evaluated to determine
67
+ the health of that service. The block should return either a status value, or
68
+ a 2-element array with status and some details.
34
69
 
35
70
  ```ruby
36
71
  AppStatus::CheckCollection.configure do |c|
37
- value = some_service_check
38
- c.add(:name => 'some_service', :status => :ok, :details => value)
72
+
73
+ c.add_check('some_service') do
74
+ details = do_something_to_check_your_service
75
+ status = (details != "FAIL") ? :ok : :critical
76
+ [status, details]
77
+ end
78
+
79
+ c.add_check('failing_service') do
80
+ :critical # you can return just a status if desired.
81
+ end
39
82
  end
40
83
  ```
41
84
 
42
- The checks that you set up here are not run when you configure them. They're
43
- run whenever someone hits the check URL.
85
+ The details string should be concise. `app_status` does its best to provide
86
+ readable output, and Nagios does its best to make this impossible to actually
87
+ do well.
44
88
 
45
- Status values (in ascending order of seriousness)
89
+ Valid status values (in ascending order of seriousness) are:
46
90
  - :ok
47
91
  - :warning
48
92
  - :critical
@@ -50,8 +94,9 @@ Status values (in ascending order of seriousness)
50
94
 
51
95
  These are set up to be compatible with Nagios.
52
96
 
53
- Details doesn't have to be a string. It can be anything which is serializable
54
- as JSON.
97
+ Keep in mind that anyone who hits your status URL can cause your checks to run,
98
+ so if they expose sensitive data or are a potential DOS vector you should
99
+ probably protect them with some kind of authentication.
55
100
 
56
101
  ## Usage
57
102
 
@@ -62,33 +107,34 @@ Output will look something like this:
62
107
  {
63
108
  "status": "critical",
64
109
  "status_code": 2,
65
- "run_time_ms": 52,
110
+ "ms": 52,
66
111
  "finished": "2013-10-03T21:28:10Z",
67
112
  "checks": {
68
113
  "some_service": {
69
114
  "status": "ok",
70
115
  "status_code": 0,
71
- "details": "Looks good!"
116
+ "details": "Looks good!",
117
+ "ms": 30
72
118
  },
73
119
  "failing_service": {
74
120
  "status": "critical",
75
121
  "status_code": 2,
76
- "details": "Oh noes!"
122
+ "details": "",
123
+ "ms": 20
77
124
  }
78
125
  }
79
126
  }
80
127
  ```
81
128
 
82
- The overall status will be the worst status which is actually observed in your
83
- individual checks.
129
+ The overall status will be the worst value observed in your individual checks.
84
130
 
85
131
  ## Nagios Integration
86
132
 
87
- [bin/check_app_status.rb](https://github.com/alexdean/app_status/blob/master/bin/check_app_status.rb)
133
+ [check_app_status.rb](check_app_status.rb)
88
134
  is a Nagios check script which can be used to monitor the output from `app_status`
89
135
 
90
136
  ```
91
- $ bin/check_app_status.rb --help
137
+ $ ./check_app_status.rb --help
92
138
  Nagios check script for app_status. See https://github.com/alexdean/app_status
93
139
  -v, --verbose Output more information
94
140
  -V, --version Output version information
@@ -102,14 +148,18 @@ The script's exit status is derived from the overall status returned by the
102
148
  server. Individual detail items will be grouped by status for display.
103
149
  (Unknowns are displayed together, then criticals, then warnings, then OKs.)
104
150
 
105
- Sample output (using verbose mode)
151
+ Sample output
106
152
 
107
153
  ```
108
- $ bin/check_app_status.rb --url http://localhost:3000/status -v
109
- 2013-10-03T20:54:16-05:00 options: {:timeout=>10, :url=>"http://localhost:3000/status"}
110
- 2013-10-03T20:54:16-05:00 timeout: 10s
111
- 2013-10-03T20:54:16-05:00 response body: {"status":"warning","status_code":1,"run_time_ms":0,"finished":"2013-10-04T01:54:16Z","details":{"some_service":{"status":"ok","status_code":0,"details":"Looks good!"},"failing_service":{"status":"warning","status_code":1,"details":"Oh noes!"}}}
154
+ $ ./check_app_status.rb --url http://localhost:3000/status
155
+
156
+ CRIT failed_service
157
+ --- failed_service: shit's on fire yo, 501ms
158
+
159
+ WARN problematic_service
160
+ --- problematic_service: not looking good, 2001ms
112
161
 
113
- WARN: failing_service:'Oh noes!'
114
- OK: some_service:'Looks good!'
162
+ OK ok_process, ok_process_2
163
+ --- ok_process: these are some details, 0ms
164
+ --- ok_process_2: more details on another process, 0ms
115
165
  ```
@@ -4,7 +4,7 @@ module AppStatus
4
4
 
5
5
  class CheckCollection
6
6
 
7
- @@config_proc = nil
7
+ @@checks = HashWithIndifferentAccess.new
8
8
 
9
9
  # Add checks here.
10
10
  #
@@ -24,11 +24,11 @@ module AppStatus
24
24
  #
25
25
  # end
26
26
  def self.configure(&block)
27
- @@config_proc = block
27
+ yield self
28
28
  end
29
29
 
30
30
  def self.clear_checks!
31
- @@config_proc = nil
31
+ @@checks = HashWithIndifferentAccess.new
32
32
  end
33
33
 
34
34
  def initialize
@@ -39,7 +39,7 @@ module AppStatus
39
39
  unknown: 3
40
40
  }.freeze
41
41
 
42
- @checks = HashWithIndifferentAccess.new
42
+ @check_results = HashWithIndifferentAccess.new
43
43
  @eval_finished = nil
44
44
  @eval_time = 0
45
45
  end
@@ -50,24 +50,15 @@ module AppStatus
50
50
  # example:
51
51
  # value = some_service_check
52
52
  # c.add(:name => 'some_service', :status => :ok, :details => value)
53
- def add(options={})
54
- raise ArgumentError, ":name option is required." if ! options[:name]
55
- raise ArgumentError, ":status option is required." if ! options[:status]
56
-
57
- name = options[:name].to_sym
58
- status = options[:status].to_sym
59
- details = options[:details].to_s
60
-
61
- # blow up if someone sends us options we don't understand.
62
- other_options = options.keys - [:name, :status, :details]
63
- if other_options.size > 0
64
- raise ArgumentError, "Unrecognized option(s) for '#{name}' check: #{other_options.join(',')}"
65
- end
53
+ def self.add_check(name, &block)
54
+ raise ArgumentError, ":name option is required." if ! name
55
+ # raise ArgumentError, ":status option is required." if ! options[:status]
66
56
 
67
- raise ArgumentError, "'#{status}' is not a valid status for check '#{name}'." if ! valid_status?(status)
68
- raise ArgumentError, "Check name '#{name}' has already been added." if @checks.keys.include?(name)
57
+ name = name.to_sym
58
+ raise ArgumentError, "Check name '#{name}' has already been added." if @@checks.keys.include?(name.to_s)
59
+ raise ArgumentError, "No check defined for '#{name}'." if ! block_given?
69
60
 
70
- @checks[name] = {status: status, status_code: @valid_status[status], details: details}
61
+ @@checks[name] = block
71
62
  end
72
63
 
73
64
  def valid_status?(status)
@@ -77,28 +68,47 @@ module AppStatus
77
68
  # run the checks added via configure
78
69
  # results of the checks are available via as_json
79
70
  def evaluate!
80
- start = Time.now
81
- @checks = {}
82
- @@config_proc.call(self) if @@config_proc
71
+ eval_start = Time.now
72
+ @check_results = {}
73
+ @@checks.each do |name,proc|
74
+ check_start = Time.now
75
+ status, details = proc.call
76
+ check_time = (Time.now - check_start) * 1000
77
+
78
+ status = status.to_sym if status
79
+ details = details.to_s if details
80
+
81
+ if ! valid_status?(status)
82
+ details = "Check returned invalid status '#{status}'. #{details}".strip
83
+ status = :unknown
84
+ end
85
+ @check_results[name] = {
86
+ status: status,
87
+ status_code: @valid_status[status],
88
+ details: details,
89
+ ms: check_time.to_i
90
+ }
91
+ end
92
+
83
93
  @eval_finished = Time.now.utc
84
- @eval_time = (Time.now - start) * 1000
94
+ @eval_time = (Time.now - eval_start) * 1000
85
95
  end
86
96
 
87
97
  def as_json
88
- if @checks.size == 0
98
+ if @check_results.size == 0
89
99
  max_status = :unknown
90
100
  max_int = @valid_status[max_status]
91
101
  else
92
- max_int = @checks.inject([]){ |memo,val| memo << val[1][:status_code]; memo}.max
102
+ max_int = @check_results.inject([]){ |memo,val| memo << val[1][:status_code]; memo}.max
93
103
  max_status = @valid_status.invert[max_int]
94
104
  end
95
105
 
96
106
  HashWithIndifferentAccess.new({
97
107
  status: max_status,
98
108
  status_code: max_int,
99
- run_time_ms: @eval_time.to_i,
109
+ ms: @eval_time.to_i,
100
110
  finished: @eval_finished.iso8601,
101
- checks: @checks
111
+ checks: @check_results
102
112
  })
103
113
  end
104
114
  end
@@ -1,3 +1,3 @@
1
1
  module AppStatus
2
- VERSION = "0.1.1"
2
+ VERSION = "1.0.0"
3
3
  end
@@ -4,9 +4,8 @@ describe AppStatus::StatusController do
4
4
 
5
5
  describe "GET index" do
6
6
  before(:each) do
7
- AppStatus::CheckCollection.configure do |c|
8
- c.add(name: 'some_service', status: :ok, details: 'foo')
9
- end
7
+ AppStatus::CheckCollection.clear_checks!
8
+ AppStatus::CheckCollection.add_check('some_service') {[:ok, 'foo']}
10
9
  end
11
10
 
12
11
  it "should render json" do
@@ -23,11 +23,11 @@ Dummy::Application.configure do
23
23
  config.action_dispatch.best_standards_support = :builtin
24
24
 
25
25
  # Raise exception on mass assignment protection for Active Record models
26
- config.active_record.mass_assignment_sanitizer = :strict
26
+ # config.active_record.mass_assignment_sanitizer = :strict
27
27
 
28
28
  # Log the query plan for queries taking more than this (works
29
29
  # with SQLite, MySQL, and PostgreSQL)
30
- config.active_record.auto_explain_threshold_in_seconds = 0.5
30
+ # config.active_record.auto_explain_threshold_in_seconds = 0.5
31
31
 
32
32
  # Do not compress assets
33
33
  config.assets.compress = false
File without changes
@@ -41,3 +41,12 @@ Processing by AppStatus::StatusController#index as JSON
41
41
  Completed 200 OK in 1ms (Views: 0.3ms)
42
42
  Processing by AppStatus::StatusController#index as HTML
43
43
  Completed 200 OK in 18ms (Views: 17.4ms)
44
+ Processing by AppStatus::StatusController#index as HTML
45
+ Rendered /Users/alex/Code/app_status/app/views/app_status/status/index.html.haml within layouts/app_status/application (0.2ms)
46
+ Completed 200 OK in 23ms (Views: 22.3ms)
47
+ Processing by AppStatus::StatusController#index as JSON
48
+ Completed 200 OK in 0ms (Views: 0.2ms)
49
+ Processing by AppStatus::StatusController#index as JSON
50
+ Completed 200 OK in 1ms (Views: 0.3ms)
51
+ Processing by AppStatus::StatusController#index as HTML
52
+ Completed 200 OK in 16ms (Views: 16.0ms)
@@ -6,81 +6,98 @@ describe AppStatus::CheckCollection do
6
6
  AppStatus::CheckCollection.clear_checks!
7
7
  end
8
8
 
9
- describe "add" do
9
+ describe "configure" do
10
10
 
11
- describe "validations" do
12
- it "should raise an error if :name is not supplied" do
13
- AppStatus::CheckCollection.configure {|c| c.add status: :ok }
14
- c = AppStatus::CheckCollection.new
15
- expect {
16
- c.evaluate!
17
- }.to raise_error(ArgumentError, ":name option is required.")
11
+ it "should yield itself" do
12
+ AppStatus::CheckCollection.configure do |c|
13
+ c.should eq AppStatus::CheckCollection
18
14
  end
15
+ end
19
16
 
20
- it "should raise an error if :status is not supplied" do
21
- AppStatus::CheckCollection.configure {|c| c.add name: 'foo' }
22
- c = AppStatus::CheckCollection.new
23
- expect {
24
- c.evaluate!
25
- }.to raise_error(ArgumentError, ":status option is required.")
26
- end
17
+ end
18
+
19
+ describe "clear_checks!" do
20
+ it "should remove all checks" do
21
+ c = AppStatus::CheckCollection.new
22
+ AppStatus::CheckCollection.add_check('test') { nil }
23
+ c.evaluate!
24
+ c.as_json['checks'].size.should eq 1
27
25
 
28
- it "should raise an error if an unrecognized option is supplied" do
29
- AppStatus::CheckCollection.configure {|c| c.add name: 'foo', status: :ok, glork: '', ping: '' }
30
- c = AppStatus::CheckCollection.new
26
+ AppStatus::CheckCollection.clear_checks!
27
+ c.evaluate!
28
+ c.as_json['checks'].size.should eq 0
29
+ end
30
+ end
31
+
32
+ describe "add_check" do
33
+
34
+ describe "validations" do
35
+ it "should raise an error if name is not supplied" do
31
36
  expect {
32
- c.evaluate!
33
- }.to raise_error(ArgumentError, "Unrecognized option(s) for 'foo' check: glork,ping")
37
+ AppStatus::CheckCollection.add_check
38
+ }.to raise_error(ArgumentError, "wrong number of arguments (0 for 1)")
34
39
  end
35
40
 
36
- it "should raise an error if :status is unrecognized" do
37
- AppStatus::CheckCollection.configure {|c| c.add name: 'foo', status: 'test' }
38
- c = AppStatus::CheckCollection.new
41
+ it "should raise an error if block is not supplied" do
39
42
  expect {
40
- c.evaluate!
41
- }.to raise_error(ArgumentError, "'test' is not a valid status for check 'foo'.")
43
+ AppStatus::CheckCollection.add_check 'some_service'
44
+ }.to raise_error(ArgumentError, "No check defined for 'some_service'.")
42
45
  end
43
46
 
44
47
  it "should raise an error if a :name is used multiple times" do
45
- AppStatus::CheckCollection.configure do |c|
46
- c.add name: 'foo', status: :ok
47
- c.add name: 'foo', status: :critical
48
- end
49
- c = AppStatus::CheckCollection.new
48
+ AppStatus::CheckCollection.add_check('foo') {[:ok, 'ok']}
50
49
  expect {
51
- c.evaluate!
50
+ AppStatus::CheckCollection.add_check('foo') {[:ok, 'ok']}
52
51
  }.to raise_error(ArgumentError, "Check name 'foo' has already been added.")
53
52
  end
54
53
  end
55
54
 
56
55
  end
57
56
 
57
+
58
58
  describe "evaluate!" do
59
+
59
60
  it "should run configured checks each time it is called" do
60
61
  counter = 0
61
- check = lambda { counter += 1; counter }
62
- AppStatus::CheckCollection.configure do |c|
63
- num_calls = check.call
64
- c.add name: 'something', status: :ok, details: num_calls
65
- end
62
+ AppStatus::CheckCollection.add_check('test') { counter += 1; [:ok, counter] }
66
63
 
67
64
  c = AppStatus::CheckCollection.new
68
65
 
69
66
  Timecop.freeze '2013-10-04T12:00:00Z' do
70
67
  c.evaluate!
71
68
  c.as_json[:finished].should eq '2013-10-04T12:00:00Z'
72
- c.as_json[:checks][:something][:details].should eq "1"
69
+ c.as_json[:checks][:test][:details].should eq "1"
73
70
  end
74
71
 
75
72
  Timecop.freeze '2013-10-04T01:00:00Z' do
76
73
  c.evaluate!
77
74
  c.as_json[:finished].should eq '2013-10-04T01:00:00Z'
78
- c.as_json[:checks][:something][:details].should eq "2"
75
+ c.as_json[:checks][:test][:details].should eq "2"
79
76
  end
80
77
  end
78
+
79
+ it "should set :unknown status for a check which does not return a status" do
80
+ AppStatus::CheckCollection.add_check('test') { nil }
81
+
82
+ c = AppStatus::CheckCollection.new
83
+ c.evaluate!
84
+ c.as_json[:checks][:test][:status].should eq :unknown
85
+ c.as_json[:checks][:test][:details].should eq "Check returned invalid status ''."
86
+ end
87
+
88
+ it "should set :unknown status for a check which returns an invalid status" do
89
+ AppStatus::CheckCollection.add_check('test') { 'huh?' }
90
+
91
+ c = AppStatus::CheckCollection.new
92
+ c.evaluate!
93
+ c.as_json[:checks][:test][:status].should eq :unknown
94
+ c.as_json[:checks][:test][:details].should eq "Check returned invalid status 'huh?'."
95
+ end
96
+
81
97
  end
82
98
 
83
99
  describe "as_json" do
100
+
84
101
  it "should use :unknown status if no checks are configured" do
85
102
  c = AppStatus::CheckCollection.new
86
103
  c.evaluate!
@@ -89,17 +106,86 @@ describe AppStatus::CheckCollection do
89
106
  data[:status].should eq :unknown
90
107
  data[:checks].should eq({})
91
108
  end
92
- end
93
109
 
94
- describe "configure" do
95
- it "should add checks to be evaluated later" do
110
+ it "should set overall status to match the worst status among configured checks" do
111
+ c = AppStatus::CheckCollection.new
112
+
113
+ AppStatus::CheckCollection.add_check('a') { :ok }
114
+
115
+ c.evaluate!
116
+ c.as_json['status'].should eq :ok
117
+ c.as_json['checks'].size.should eq 1
118
+
119
+ AppStatus::CheckCollection.add_check('b') { :warning }
120
+
121
+ c.evaluate!
122
+ c.as_json['status'].should eq :warning
123
+ c.as_json['checks'].size.should eq 2
124
+
125
+ AppStatus::CheckCollection.add_check('c') { :critical }
126
+
127
+ c.evaluate!
128
+ c.as_json['status'].should eq :critical
129
+ c.as_json['checks'].size.should eq 3
130
+
131
+ AppStatus::CheckCollection.add_check('d') { :unknown }
132
+
133
+ c.evaluate!
134
+ c.as_json['status'].should eq :unknown
135
+ c.as_json['checks'].size.should eq 4
136
+ end
137
+
138
+ it "should include details on all checks" do
96
139
  AppStatus::CheckCollection.configure do |c|
97
- c.add name: 'something', status: :ok
140
+ c.add_check('test1') { [:ok, 'looks good'] }
141
+ c.add_check('test2') { [:huh, 'invalid'] }
142
+ c.add_check('test3') { [:warning, 'not good'] }
143
+ c.add_check('test4') { [:critical, 'on fire'] }
144
+ c.add_check('test5') { [:unknown, 'no idea'] }
98
145
  end
99
146
 
100
147
  c = AppStatus::CheckCollection.new
101
- c.evaluate!
102
- data = c.as_json
148
+ Timecop.freeze('2013-10-05T12:00:00Z') { c.evaluate! }
149
+
150
+ c.as_json.should eq({
151
+ "status" => :unknown,
152
+ "status_code" => 3,
153
+ "ms" => instance_of(Fixnum),
154
+ "finished" => "2013-10-05T12:00:00Z",
155
+ "checks" => {
156
+ "test1" => {
157
+ "status" => :ok,
158
+ "status_code" => 0,
159
+ "details" => "looks good",
160
+ "ms" => instance_of(Fixnum)
161
+ },
162
+ "test2" => {
163
+ "status" => :unknown,
164
+ "status_code" => 3,
165
+ "details" => "Check returned invalid status 'huh'. invalid",
166
+ "ms" => instance_of(Fixnum)
167
+ },
168
+ "test3" => {
169
+ "status" => :warning,
170
+ "status_code" => 1,
171
+ "details" => "not good",
172
+ "ms" => instance_of(Fixnum)
173
+ },
174
+ "test4" => {
175
+ "status" => :critical,
176
+ "status_code" => 2,
177
+ "details" => "on fire",
178
+ "ms" => instance_of(Fixnum)
179
+ },
180
+ "test5" => {
181
+ "status" => :unknown,
182
+ "status_code" => 3,
183
+ "details" => "no idea",
184
+ "ms" => instance_of(Fixnum)
185
+ }
186
+ }
187
+ })
103
188
  end
189
+
104
190
  end
105
191
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: app_status
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 1.0.0
5
5
  prerelease:
6
6
  platform: ruby
7
7
  authors:
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2013-10-04 00:00:00.000000000 Z
12
+ date: 2013-10-07 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: rails
@@ -168,6 +168,7 @@ files:
168
168
  - spec/dummy/config/locales/en.yml
169
169
  - spec/dummy/config/routes.rb
170
170
  - spec/dummy/config.ru
171
+ - spec/dummy/log/development.log
171
172
  - spec/dummy/log/test.log
172
173
  - spec/dummy/public/404.html
173
174
  - spec/dummy/public/422.html
@@ -192,7 +193,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
192
193
  version: '0'
193
194
  segments:
194
195
  - 0
195
- hash: 2540672613882673214
196
+ hash: 4001732300146768489
196
197
  required_rubygems_version: !ruby/object:Gem::Requirement
197
198
  none: false
198
199
  requirements:
@@ -201,7 +202,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
201
202
  version: '0'
202
203
  segments:
203
204
  - 0
204
- hash: 2540672613882673214
205
+ hash: 4001732300146768489
205
206
  requirements: []
206
207
  rubyforge_project:
207
208
  rubygems_version: 1.8.24
@@ -231,6 +232,7 @@ test_files:
231
232
  - spec/dummy/config/locales/en.yml
232
233
  - spec/dummy/config/routes.rb
233
234
  - spec/dummy/config.ru
235
+ - spec/dummy/log/development.log
234
236
  - spec/dummy/log/test.log
235
237
  - spec/dummy/public/404.html
236
238
  - spec/dummy/public/422.html