cased-ruby 0.3.3 → 0.4.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.
- checksums.yaml +4 -4
- data/Gemfile.lock +20 -15
- data/README.md +14 -41
- data/bin/cli +14 -0
- data/cased-ruby.gemspec +2 -0
- data/lib/cased.rb +1 -0
- data/lib/cased/cli.rb +13 -0
- data/lib/cased/cli/asciinema/file.rb +108 -0
- data/lib/cased/cli/asciinema/writer.rb +67 -0
- data/lib/cased/cli/authentication.rb +31 -0
- data/lib/cased/cli/identity.rb +38 -0
- data/lib/cased/cli/interactive_session.rb +84 -0
- data/lib/cased/cli/log.rb +25 -0
- data/lib/cased/cli/recorder.rb +57 -0
- data/lib/cased/cli/session.rb +278 -0
- data/lib/cased/clients.rb +7 -3
- data/lib/cased/config.rb +40 -0
- data/lib/cased/http/client.rb +13 -6
- data/lib/cased/http/error.rb +5 -2
- data/lib/cased/query.rb +6 -3
- data/lib/cased/version.rb +1 -1
- data/vendor/cache/activesupport-6.1.3.gem +0 -0
- data/vendor/cache/concurrent-ruby-1.1.8.gem +0 -0
- data/vendor/cache/faraday-1.3.0.gem +0 -0
- data/vendor/cache/faraday-net_http-1.0.1.gem +0 -0
- data/vendor/cache/i18n-1.8.9.gem +0 -0
- data/vendor/cache/json-2.5.1.gem +0 -0
- data/vendor/cache/jwt-2.2.2.gem +0 -0
- data/vendor/cache/ruby2_keywords-0.0.4.gem +0 -0
- data/vendor/cache/subprocess-1.5.4.gem +0 -0
- data/vendor/cache/tzinfo-2.0.4.gem +0 -0
- data/vendor/cache/zeitwerk-2.4.2.gem +0 -0
- metadata +51 -11
- data/vendor/cache/activesupport-6.0.3.4.gem +0 -0
- data/vendor/cache/concurrent-ruby-1.1.7.gem +0 -0
- data/vendor/cache/faraday-1.1.0.gem +0 -0
- data/vendor/cache/i18n-1.8.5.gem +0 -0
- data/vendor/cache/json-2.3.1.gem +0 -0
- data/vendor/cache/ruby2_keywords-0.0.2.gem +0 -0
- data/vendor/cache/thread_safe-0.3.6.gem +0 -0
- data/vendor/cache/tzinfo-1.2.7.gem +0 -0
- data/vendor/cache/zeitwerk-2.4.0.gem +0 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cased
|
|
4
|
+
module CLI
|
|
5
|
+
class Identity
|
|
6
|
+
def initialize
|
|
7
|
+
@timeout = 30
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def identify
|
|
11
|
+
response = Cased.clients.cli.post('cli/applications/users/identify')
|
|
12
|
+
case response.status
|
|
13
|
+
when 201 # Created
|
|
14
|
+
url = response.body.fetch('url')
|
|
15
|
+
Cased::CLI::Log.log 'To login, please visit:'
|
|
16
|
+
puts url
|
|
17
|
+
poll(response.body['api_url'])
|
|
18
|
+
when 401 # Unauthorized
|
|
19
|
+
false
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def poll(poll_url)
|
|
24
|
+
count = 0
|
|
25
|
+
user_id = nil
|
|
26
|
+
|
|
27
|
+
while user_id.nil?
|
|
28
|
+
count += 1
|
|
29
|
+
response = Cased.clients.cli.get(poll_url)
|
|
30
|
+
user_id = response.body.dig('user', 'id')
|
|
31
|
+
sleep 1 if user_id.nil?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
user_id
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'cased/cli/session'
|
|
4
|
+
|
|
5
|
+
module Cased
|
|
6
|
+
module CLI
|
|
7
|
+
# InteractiveSession is responsible for initiating a Cased CLI session and
|
|
8
|
+
# responding to all its possible states.
|
|
9
|
+
#
|
|
10
|
+
# InteractiveSession is intended to be used where a TTY is present to handle
|
|
11
|
+
# the entire flow from authentication, reason required, waiting for
|
|
12
|
+
# approval, canceled, or timed out.
|
|
13
|
+
class InteractiveSession
|
|
14
|
+
def self.start(reason: nil, command: nil, metadata: {})
|
|
15
|
+
return Cased::CLI::Session.current if Cased::CLI::Session.current&.approved?
|
|
16
|
+
|
|
17
|
+
Cased::CLI::Log.log 'Running under Cased CLI.'
|
|
18
|
+
|
|
19
|
+
new(reason: reason, command: command, metadata: metadata).create
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
attr_reader :session
|
|
23
|
+
|
|
24
|
+
def initialize(reason: nil, command: nil, metadata: {})
|
|
25
|
+
@session = Cased::CLI::Session.new(
|
|
26
|
+
reason: reason,
|
|
27
|
+
command: command,
|
|
28
|
+
metadata: metadata,
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def create
|
|
33
|
+
if session.create
|
|
34
|
+
handle_state(session.state)
|
|
35
|
+
elsif session.unauthorized?
|
|
36
|
+
if session.authentication.exists?
|
|
37
|
+
Cased::CLI::Log.log "Existing credentials at #{session.authentication.credentials_path} are not valid."
|
|
38
|
+
else
|
|
39
|
+
Cased::CLI::Log.log "Could not find credentials at #{session.authentication.credentials_path}, looking up now…"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
identity = Cased::CLI::Identity.new
|
|
43
|
+
session.authentication.token = identity.identify
|
|
44
|
+
|
|
45
|
+
create
|
|
46
|
+
elsif session.reason_required?
|
|
47
|
+
reason_prompt && create
|
|
48
|
+
else
|
|
49
|
+
Cased::CLI::Log.log 'Could not start CLI session.'
|
|
50
|
+
exit 1 if Cased.config.guard_deny_if_unreachable?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
session
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def reason_prompt
|
|
59
|
+
print Cased::CLI::Log.string 'Please enter a reason for access: '
|
|
60
|
+
session.reason = gets.chomp
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def wait_for_approval
|
|
64
|
+
session.refresh && handle_state(session.state)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def handle_state(state)
|
|
68
|
+
case state
|
|
69
|
+
when 'approved'
|
|
70
|
+
Cased::CLI::Log.log 'CLI session has been approved'
|
|
71
|
+
session.record
|
|
72
|
+
when 'requested'
|
|
73
|
+
wait_for_approval
|
|
74
|
+
when 'denied'
|
|
75
|
+
Cased::CLI::Log.log 'CLI session has been denied'
|
|
76
|
+
when 'timed_out'
|
|
77
|
+
Cased::CLI::Log.log 'CLI session has timed out'
|
|
78
|
+
when 'canceled'
|
|
79
|
+
Cased::CLI::Log.log 'CLI session has been canceled'
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cased
|
|
4
|
+
module CLI
|
|
5
|
+
module Log
|
|
6
|
+
CLEAR = "\e[0m"
|
|
7
|
+
YELLOW = "\e[33m"
|
|
8
|
+
BOLD = "\e[1m"
|
|
9
|
+
|
|
10
|
+
def self.string(text)
|
|
11
|
+
[color('[cased]', YELLOW, true), text].join(' ')
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.log(text)
|
|
15
|
+
puts string(text)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.color(text, color, bold = false)
|
|
19
|
+
color = self.class.const_get(color.upcase) if color.is_a?(Symbol)
|
|
20
|
+
bold = bold ? BOLD : ''
|
|
21
|
+
"#{bold}#{color}#{text}#{CLEAR}"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'subprocess'
|
|
4
|
+
|
|
5
|
+
module Cased
|
|
6
|
+
module CLI
|
|
7
|
+
class Recorder
|
|
8
|
+
KEY = 'CASED_CLI_RECORDING'
|
|
9
|
+
|
|
10
|
+
attr_reader :command
|
|
11
|
+
attr_reader :events
|
|
12
|
+
attr_reader :started_at
|
|
13
|
+
attr_reader :width
|
|
14
|
+
attr_reader :height
|
|
15
|
+
attr_reader :options
|
|
16
|
+
attr_accessor :writer
|
|
17
|
+
|
|
18
|
+
# @return [Boolean] if CLI session is being recorded.
|
|
19
|
+
def self.recording?
|
|
20
|
+
ENV[KEY] == '1'
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(command, env: {})
|
|
24
|
+
@command = command
|
|
25
|
+
@events = []
|
|
26
|
+
@width = Subprocess.check_output(%w[tput cols]).strip.to_i
|
|
27
|
+
@height = Subprocess.check_output(%w[tput lines]).strip.to_i
|
|
28
|
+
|
|
29
|
+
subprocess_env = ENV.to_h.dup
|
|
30
|
+
subprocess_env[KEY] = '1'
|
|
31
|
+
subprocess_env.merge!(env)
|
|
32
|
+
@writer = Cased::CLI::Asciinema::Writer.new(
|
|
33
|
+
command: command.join(' '),
|
|
34
|
+
width: width,
|
|
35
|
+
height: height,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
@options = {
|
|
39
|
+
stdout: Subprocess::PIPE,
|
|
40
|
+
env: subprocess_env,
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def start
|
|
45
|
+
writer.time do
|
|
46
|
+
Subprocess.check_call(command, options) do |t|
|
|
47
|
+
t.communicate do |stdout, _stderr|
|
|
48
|
+
STDOUT.write(stdout)
|
|
49
|
+
|
|
50
|
+
writer << stdout.gsub("\n", "\r\n")
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'cased/cli/authentication'
|
|
4
|
+
require 'cased/cli/identity'
|
|
5
|
+
require 'cased/cli/recorder'
|
|
6
|
+
require 'cased/model'
|
|
7
|
+
|
|
8
|
+
module Cased
|
|
9
|
+
module CLI
|
|
10
|
+
class Session
|
|
11
|
+
include Cased::Model
|
|
12
|
+
|
|
13
|
+
def self.find(guard_session_id)
|
|
14
|
+
authentication = Cased::CLI::Authentication.new
|
|
15
|
+
|
|
16
|
+
response = Cased.clients.cli.get("cli/sessions/#{guard_session_id}", user_token: authentication.token)
|
|
17
|
+
new.tap do |session|
|
|
18
|
+
session.session = response.body
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# If we're inside of a recorded session we can lookup the session
|
|
23
|
+
# we're in.
|
|
24
|
+
def self.current
|
|
25
|
+
return @current if defined?(@current)
|
|
26
|
+
|
|
27
|
+
@current = if ENV['GUARD_SESSION_ID']
|
|
28
|
+
Cased::CLI::Session.find(ENV['GUARD_SESSION_ID'])
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.current?
|
|
33
|
+
current.present?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
class << self
|
|
37
|
+
attr_writer :current
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [Cased::CLI::Authentication]
|
|
41
|
+
attr_reader :authentication
|
|
42
|
+
|
|
43
|
+
# Public: The CLI session ID
|
|
44
|
+
# @example
|
|
45
|
+
# session.id #=> "guard_session_1oFqm5GBQYwhH8pfIpnS0A5QgFJ"
|
|
46
|
+
# @return [String, nil]
|
|
47
|
+
attr_reader :id
|
|
48
|
+
|
|
49
|
+
# Public: The CLI session web URL
|
|
50
|
+
# @example
|
|
51
|
+
# session.url #=> "https://api.cased.com/cli/programs/ruby/sessions/guard_session_1oFqm5GBQYwhH8pfIpnS0A5QgFJ"
|
|
52
|
+
# @return [String, nil]
|
|
53
|
+
attr_reader :url
|
|
54
|
+
|
|
55
|
+
# Public: The CLI session API URL
|
|
56
|
+
# @example
|
|
57
|
+
# session.api_url #=> "https://api.cased.com/cli/sessions/guard_session_1oFqm5GBQYwhH8pfIpnS0A5QgFJ"
|
|
58
|
+
# @return [String, nil]
|
|
59
|
+
attr_reader :api_url
|
|
60
|
+
|
|
61
|
+
# Public: The CLI session record API URL
|
|
62
|
+
# @example
|
|
63
|
+
# session.api_record_url #=> "https://api.cased.com/cli/sessions/guard_session_1oFqm5GBQYwhH8pfIpnS0A5QgFJ/record"
|
|
64
|
+
# @return [String, nil]
|
|
65
|
+
attr_reader :api_record_url
|
|
66
|
+
|
|
67
|
+
# Public: The current state the CLI session is in
|
|
68
|
+
# @example
|
|
69
|
+
# session.api_url #=> "approved"
|
|
70
|
+
# @return [String, nil]
|
|
71
|
+
attr_reader :state
|
|
72
|
+
|
|
73
|
+
# Public: Command that invoked CLI session.
|
|
74
|
+
# @example
|
|
75
|
+
# session.command #=> "/usr/local/bin/rails console"
|
|
76
|
+
# @return [String]
|
|
77
|
+
attr_accessor :command
|
|
78
|
+
|
|
79
|
+
# Public: Additional user supplied metadata about the CLI session.
|
|
80
|
+
# @example
|
|
81
|
+
# session.metadata #=> {"hostname" => "Mac.local"}
|
|
82
|
+
# @return [Hash]
|
|
83
|
+
attr_accessor :metadata
|
|
84
|
+
|
|
85
|
+
# Public: The user supplied reason for the CLI session for taking place.
|
|
86
|
+
# @example
|
|
87
|
+
# session.reason #=> "Investigating customer support ticket."
|
|
88
|
+
# @return [String, nil]
|
|
89
|
+
attr_accessor :reason
|
|
90
|
+
|
|
91
|
+
# Public: The client's IP V4 or IP V6 address that initiated the CLI session.
|
|
92
|
+
# @example
|
|
93
|
+
# session.reason #=> "1.1.1.1"
|
|
94
|
+
# @return [String, nil]
|
|
95
|
+
attr_reader :ip_address
|
|
96
|
+
|
|
97
|
+
# Public: The Cased user that requested the CLI session.
|
|
98
|
+
# @example
|
|
99
|
+
# session.requester #=> {"id" => "user_1oFqlROLNRGVLOXJSsHkJiVmylr"}
|
|
100
|
+
# @return [Hash, nil]
|
|
101
|
+
attr_reader :requester
|
|
102
|
+
|
|
103
|
+
# Public: The Cased user that requested the CLI session.
|
|
104
|
+
# @example
|
|
105
|
+
# session.responded_at #=> "2021-02-10 12:08:44 -0800"
|
|
106
|
+
# @return [Time, nil]
|
|
107
|
+
attr_reader :responded_at
|
|
108
|
+
|
|
109
|
+
# Public: The Cased user that responded to the CLI session.
|
|
110
|
+
# @example
|
|
111
|
+
# session.responder #=> {"id" => "user_1oFqlROLNRGVLOXJSsHkJiVmylr"}
|
|
112
|
+
# @return [Hash, nil]
|
|
113
|
+
attr_reader :responder
|
|
114
|
+
|
|
115
|
+
# Public: The CLI application that the CLI session belongs to.
|
|
116
|
+
# @example
|
|
117
|
+
# session.guard_application #=> {"id" => "guard_application_1oFqltbMqSEtJQKRCAYQNrQoXsS"}
|
|
118
|
+
# @return [Hash, nil]
|
|
119
|
+
attr_reader :guard_application
|
|
120
|
+
|
|
121
|
+
def initialize(reason: nil, command: nil, metadata: {}, authentication: nil)
|
|
122
|
+
@authentication = authentication || Cased::CLI::Authentication.new
|
|
123
|
+
@reason = reason
|
|
124
|
+
@command = command
|
|
125
|
+
@metadata = metadata
|
|
126
|
+
@requester = {}
|
|
127
|
+
@responder = {}
|
|
128
|
+
@guard_application = {}
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def to_s
|
|
132
|
+
command
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def to_param
|
|
136
|
+
id
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def session=(session)
|
|
140
|
+
@error = nil
|
|
141
|
+
@id = session.fetch('id')
|
|
142
|
+
@api_url = session.fetch('api_url')
|
|
143
|
+
@api_record_url = session.fetch('api_record_url')
|
|
144
|
+
@url = session.fetch('url')
|
|
145
|
+
@state = session.fetch('state')
|
|
146
|
+
@command = session.fetch('command')
|
|
147
|
+
@metadata = session.fetch('metadata')
|
|
148
|
+
@reason = session.fetch('reason')
|
|
149
|
+
@ip_address = session.fetch('ip_address')
|
|
150
|
+
@requester = session.fetch('requester')
|
|
151
|
+
@responded_at = session['responded_at']
|
|
152
|
+
@responder = session['responder'] || {}
|
|
153
|
+
@guard_application = session.fetch('guard_application')
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def requested?
|
|
157
|
+
state == 'requested'
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def approved?
|
|
161
|
+
state == 'approved'
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def denied?
|
|
165
|
+
state == 'denied'
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def canceled?
|
|
169
|
+
state == 'canceled'
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def timed_out?
|
|
173
|
+
state == 'timed_out'
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def refresh
|
|
177
|
+
return false unless api_url
|
|
178
|
+
|
|
179
|
+
response = Cased.clients.cli.get(api_url, user_token: authentication.token)
|
|
180
|
+
self.session = response.body if response.success?
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def error?
|
|
184
|
+
!error.nil?
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def success?
|
|
188
|
+
id && !error?
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def reason_required?
|
|
192
|
+
error == :reason_required || guard_application.dig('settings', 'reason_required')
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def unauthorized?
|
|
196
|
+
error == :unauthorized
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def record_output?
|
|
200
|
+
guard_application.dig('settings', 'record_output') || false
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def record
|
|
204
|
+
return unless recordable? && record_output?
|
|
205
|
+
|
|
206
|
+
Cased::CLI::Log.log 'CLI session is now recording'
|
|
207
|
+
|
|
208
|
+
recorder = Cased::CLI::Recorder.new(command.split(' '), env: {
|
|
209
|
+
'GUARD_SESSION_ID' => id,
|
|
210
|
+
'GUARD_APPLICATION_ID' => guard_application.fetch('id'),
|
|
211
|
+
'GUARD_USER_TOKEN' => requester.fetch('id'),
|
|
212
|
+
})
|
|
213
|
+
recorder.start
|
|
214
|
+
|
|
215
|
+
Cased.clients.cli.put(api_record_url,
|
|
216
|
+
recording: recorder.writer.to_cast,
|
|
217
|
+
user_token: authentication.token)
|
|
218
|
+
|
|
219
|
+
Cased::CLI::Log.log 'CLI session recorded'
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def create
|
|
223
|
+
return false unless id.nil?
|
|
224
|
+
|
|
225
|
+
response = Cased.clients.cli.post('cli/sessions',
|
|
226
|
+
user_token: authentication.token,
|
|
227
|
+
reason: reason,
|
|
228
|
+
metadata: metadata,
|
|
229
|
+
command: command)
|
|
230
|
+
if response.success?
|
|
231
|
+
self.session = response.body
|
|
232
|
+
else
|
|
233
|
+
case response.body['error']
|
|
234
|
+
when 'reason_required'
|
|
235
|
+
@error = :reason_required
|
|
236
|
+
when 'unauthorized'
|
|
237
|
+
@error = :unauthorized
|
|
238
|
+
else
|
|
239
|
+
@error = true
|
|
240
|
+
return false
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
response.success?
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def cancel
|
|
248
|
+
response = Cased.clients.cli.post("#{api_url}/cancel", user_token: authentication.token)
|
|
249
|
+
self.session = response.body if response.success?
|
|
250
|
+
|
|
251
|
+
canceled?
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def cased_category
|
|
255
|
+
:cli
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def cased_id
|
|
259
|
+
id
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def cased_context(category: cased_category)
|
|
263
|
+
{
|
|
264
|
+
"#{category}_id".to_sym => cased_id,
|
|
265
|
+
category.to_sym => to_s,
|
|
266
|
+
}
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def recordable?
|
|
270
|
+
STDOUT.isatty
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
private
|
|
274
|
+
|
|
275
|
+
attr_reader :error
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|