aptible-cli 0.16.2 → 0.16.7
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 +1 -1
- data/README.md +48 -45
- data/aptible-cli.gemspec +2 -2
- data/lib/aptible/cli/agent.rb +78 -9
- data/lib/aptible/cli/helpers/database.rb +23 -0
- data/lib/aptible/cli/helpers/security_key.rb +136 -0
- data/lib/aptible/cli/helpers/system.rb +26 -0
- data/lib/aptible/cli/resource_formatter.rb +36 -1
- data/lib/aptible/cli/subcommands/backup.rb +56 -12
- data/lib/aptible/cli/subcommands/db.rb +66 -6
- data/lib/aptible/cli/subcommands/inspect.rb +1 -1
- data/lib/aptible/cli/version.rb +1 -1
- data/spec/aptible/cli/agent_spec.rb +131 -9
- data/spec/aptible/cli/subcommands/backup_spec.rb +79 -2
- data/spec/aptible/cli/subcommands/db_spec.rb +147 -2
- metadata +9 -8
@@ -4,6 +4,41 @@ module Aptible
|
|
4
4
|
class << self
|
5
5
|
NO_NESTING = Object.new.freeze
|
6
6
|
|
7
|
+
def inject_backup(node, backup, include_db: false)
|
8
|
+
description = "#{backup.id}: #{backup.created_at}, " \
|
9
|
+
"#{backup.aws_region}"
|
10
|
+
|
11
|
+
if include_db
|
12
|
+
db = backup.database_with_deleted
|
13
|
+
node.keyed_object('database', 'id') do |n|
|
14
|
+
inject_deleted_database(n, db, backup.account)
|
15
|
+
end
|
16
|
+
|
17
|
+
description = "#{description}, " \
|
18
|
+
"#{db.handle} deleted at #{db.deleted_at}"
|
19
|
+
end
|
20
|
+
|
21
|
+
node.value('id', backup.id)
|
22
|
+
node.value('description', description)
|
23
|
+
node.value('created_at', backup.created_at)
|
24
|
+
node.value('region', backup.aws_region)
|
25
|
+
node.value('size', backup.size)
|
26
|
+
|
27
|
+
if backup.copied_from
|
28
|
+
node.keyed_object('copied_from', 'description') do |n|
|
29
|
+
inject_backup(n, backup.copied_from)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def inject_deleted_database(node, database, account)
|
35
|
+
node.value('id', database.id)
|
36
|
+
node.value('handle', database.handle)
|
37
|
+
node.value('type', database.type)
|
38
|
+
node.value('deleted_at', database.deleted_at)
|
39
|
+
attach_account(node, account)
|
40
|
+
end
|
41
|
+
|
7
42
|
def inject_account(node, account)
|
8
43
|
node.value('id', account.id)
|
9
44
|
node.value('handle', account.handle)
|
@@ -47,6 +82,7 @@ module Aptible
|
|
47
82
|
|
48
83
|
node.value('type', database.type)
|
49
84
|
node.value('status', database.status)
|
85
|
+
|
50
86
|
node.value('connection_url', database.connection_url)
|
51
87
|
|
52
88
|
node.list('credentials') do |creds_list|
|
@@ -54,7 +90,6 @@ module Aptible
|
|
54
90
|
creds_list.object { |n| inject_credential(n, cred) }
|
55
91
|
end
|
56
92
|
end
|
57
|
-
|
58
93
|
attach_account(node, account)
|
59
94
|
end
|
60
95
|
|
@@ -9,12 +9,13 @@ module Aptible
|
|
9
9
|
|
10
10
|
desc 'backup:restore BACKUP_ID ' \
|
11
11
|
'[--environment ENVIRONMENT_HANDLE] [--handle HANDLE] ' \
|
12
|
-
'[--container-size SIZE_MB] [--size SIZE_GB]',
|
12
|
+
'[--container-size SIZE_MB] [--disk-size SIZE_GB]',
|
13
13
|
'Restore a backup'
|
14
14
|
option :handle, desc: 'a name to use for the new database'
|
15
15
|
option :environment, desc: 'a different environment to restore to'
|
16
16
|
option :container_size, type: :numeric
|
17
17
|
option :size, type: :numeric
|
18
|
+
option :disk_size, type: :numeric
|
18
19
|
define_method 'backup:restore' do |backup_id|
|
19
20
|
backup = Aptible::Api::Backup.find(backup_id, token: fetch_token)
|
20
21
|
raise Thor::Error, "Backup ##{backup_id} not found" if backup.nil?
|
@@ -22,7 +23,8 @@ module Aptible
|
|
22
23
|
handle = options[:handle]
|
23
24
|
unless handle
|
24
25
|
ts_suffix = backup.created_at.getgm.strftime '%Y-%m-%d-%H-%M-%S'
|
25
|
-
handle =
|
26
|
+
handle =
|
27
|
+
"#{backup.database_with_deleted.handle}-at-#{ts_suffix}"
|
26
28
|
end
|
27
29
|
|
28
30
|
destination_account = if options[:environment]
|
@@ -35,10 +37,16 @@ module Aptible
|
|
35
37
|
type: 'restore',
|
36
38
|
handle: handle,
|
37
39
|
container_size: options[:container_size],
|
38
|
-
disk_size: options[:size],
|
40
|
+
disk_size: options[:disk_size] || options[:size],
|
39
41
|
destination_account: destination_account
|
40
42
|
}.delete_if { |_, v| v.nil? }
|
41
43
|
|
44
|
+
CLI.logger.warn([
|
45
|
+
'You have used the "--size" option to specify a disk size.',
|
46
|
+
'This option which be deprecated in a future version.',
|
47
|
+
'Please use the "--disk-size" option, instead.'
|
48
|
+
].join("\n")) if options[:size]
|
49
|
+
|
42
50
|
operation = backup.create_operation!(opts)
|
43
51
|
CLI.logger.info "Restoring backup into #{handle}"
|
44
52
|
attach_to_operation_logs(operation)
|
@@ -62,22 +70,58 @@ module Aptible
|
|
62
70
|
database = ensure_database(options.merge(db: handle))
|
63
71
|
|
64
72
|
Formatter.render(Renderer.current) do |root|
|
65
|
-
root.keyed_list('description') do |
|
73
|
+
root.keyed_list('description') do |node|
|
66
74
|
database.each_backup do |backup|
|
67
|
-
|
68
|
-
|
69
|
-
|
75
|
+
if backup.created_at < min_created_at && !backup.copied_from
|
76
|
+
break
|
77
|
+
end
|
78
|
+
node.object do |n|
|
79
|
+
ResourceFormatter.inject_backup(n, backup)
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
70
85
|
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
86
|
+
desc 'backup:orphaned', 'List backups associated with ' \
|
87
|
+
'deprovisioned databases'
|
88
|
+
option :environment
|
89
|
+
option :max_age, default: '1y',
|
90
|
+
desc: 'Limit backups returned '\
|
91
|
+
'(example usage: 1w, 1y, etc.)'
|
92
|
+
define_method 'backup:orphaned' do
|
93
|
+
age = ChronicDuration.parse(options[:max_age])
|
94
|
+
raise Thor::Error, "Invalid age: #{options[:max_age]}" if age.nil?
|
95
|
+
min_created_at = Time.now - age
|
96
|
+
|
97
|
+
Formatter.render(Renderer.current) do |root|
|
98
|
+
root.keyed_list('description') do |node|
|
99
|
+
scoped_environments(options).each do |account|
|
100
|
+
account.each_orphaned_backup do |backup|
|
101
|
+
created_at = backup.created_at
|
102
|
+
copied_from = backup.copied_from
|
103
|
+
break if created_at < min_created_at && !copied_from
|
104
|
+
node.object do |n|
|
105
|
+
ResourceFormatter.inject_backup(
|
106
|
+
n, backup, include_db: true
|
107
|
+
)
|
108
|
+
end
|
76
109
|
end
|
77
110
|
end
|
78
111
|
end
|
79
112
|
end
|
80
113
|
end
|
114
|
+
|
115
|
+
desc 'backup:purge BACKUP_ID',
|
116
|
+
'Permanently delete a backup and any copies of it'
|
117
|
+
define_method 'backup:purge' do |backup_id|
|
118
|
+
backup = Aptible::Api::Backup.find(backup_id, token: fetch_token)
|
119
|
+
raise Thor::Error, "Backup ##{backup_id} not found" if backup.nil?
|
120
|
+
|
121
|
+
operation = backup.create_operation!(type: 'purge')
|
122
|
+
CLI.logger.info "Purging backup #{backup_id}"
|
123
|
+
attach_to_operation_logs(operation)
|
124
|
+
end
|
81
125
|
end
|
82
126
|
end
|
83
127
|
end
|
@@ -53,12 +53,13 @@ module Aptible
|
|
53
53
|
|
54
54
|
desc 'db:create HANDLE ' \
|
55
55
|
'[--type TYPE] [--version VERSION] ' \
|
56
|
-
'[--container-size SIZE_MB] [--size SIZE_GB]',
|
56
|
+
'[--container-size SIZE_MB] [--disk-size SIZE_GB]',
|
57
57
|
'Create a new database'
|
58
58
|
option :type, type: :string
|
59
59
|
option :version, type: :string
|
60
60
|
option :container_size, type: :numeric
|
61
|
-
option :size,
|
61
|
+
option :size, type: :numeric
|
62
|
+
option :disk_size, default: 10, type: :numeric
|
62
63
|
option :environment
|
63
64
|
define_method 'db:create' do |handle|
|
64
65
|
account = ensure_environment(options)
|
@@ -66,9 +67,15 @@ module Aptible
|
|
66
67
|
db_opts = {
|
67
68
|
handle: handle,
|
68
69
|
initial_container_size: options[:container_size],
|
69
|
-
initial_disk_size: options[:size]
|
70
|
+
initial_disk_size: options[:disk_size] || options[:size]
|
70
71
|
}.delete_if { |_, v| v.nil? }
|
71
72
|
|
73
|
+
CLI.logger.warn([
|
74
|
+
'You have used the "--size" option to specify a disk size.',
|
75
|
+
'This option which be deprecated in a future version.',
|
76
|
+
'Please use the "--disk-size" option, instead.'
|
77
|
+
].join("\n")) if options[:size]
|
78
|
+
|
72
79
|
type = options[:type]
|
73
80
|
version = options[:version]
|
74
81
|
|
@@ -87,7 +94,7 @@ module Aptible
|
|
87
94
|
op_opts = {
|
88
95
|
type: 'provision',
|
89
96
|
container_size: options[:container_size],
|
90
|
-
disk_size: options[:size]
|
97
|
+
disk_size: options[:disk_size] || options[:size]
|
91
98
|
}.delete_if { |_, v| v.nil? }
|
92
99
|
op = database.create_operation(op_opts)
|
93
100
|
|
@@ -114,6 +121,52 @@ module Aptible
|
|
114
121
|
render_database(database, database.account)
|
115
122
|
end
|
116
123
|
|
124
|
+
desc 'db:replicate HANDLE REPLICA_HANDLE ' \
|
125
|
+
'[--container-size SIZE_MB] [--disk-size SIZE_GB] ' \
|
126
|
+
'[--logical --version VERSION]',
|
127
|
+
'Create a replica/follower of a database'
|
128
|
+
option :environment
|
129
|
+
option :container_size, type: :numeric
|
130
|
+
option :size, type: :numeric
|
131
|
+
option :disk_size, type: :numeric
|
132
|
+
option :logical, type: :boolean
|
133
|
+
option :version, type: :string
|
134
|
+
define_method 'db:replicate' do |source_handle, dest_handle|
|
135
|
+
source = ensure_database(options.merge(db: source_handle))
|
136
|
+
|
137
|
+
if options[:logical]
|
138
|
+
if source.type != 'postgresql'
|
139
|
+
raise Thor::Error, 'Logical replication only works for ' \
|
140
|
+
'PostgreSQL'
|
141
|
+
end
|
142
|
+
if options[:version]
|
143
|
+
image = find_database_image(source.type, options[:version])
|
144
|
+
else
|
145
|
+
raise Thor::Error, '--version is required for logical ' \
|
146
|
+
'replication'
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
CLI.logger.info "Replicating #{source_handle}..."
|
151
|
+
|
152
|
+
opts = {
|
153
|
+
environment: options[:environment],
|
154
|
+
container_size: options[:container_size],
|
155
|
+
size: options[:disk_size] || options[:size],
|
156
|
+
logical: options[:logical],
|
157
|
+
database_image: image || nil
|
158
|
+
}.delete_if { |_, v| v.nil? }
|
159
|
+
|
160
|
+
CLI.logger.warn([
|
161
|
+
'You have used the "--size" option to specify a disk size.',
|
162
|
+
'This option which be deprecated in a future version.',
|
163
|
+
'Please use the "--disk-size" option, instead.'
|
164
|
+
].join("\n")) if options[:size]
|
165
|
+
|
166
|
+
database = replicate_database(source, dest_handle, opts)
|
167
|
+
render_database(database.reload, database.account)
|
168
|
+
end
|
169
|
+
|
117
170
|
desc 'db:dump HANDLE [pg_dump options]',
|
118
171
|
'Dump a remote database to file'
|
119
172
|
option :environment
|
@@ -221,10 +274,11 @@ module Aptible
|
|
221
274
|
end
|
222
275
|
|
223
276
|
desc 'db:restart HANDLE ' \
|
224
|
-
'[--container-size SIZE_MB] [--size SIZE_GB]',
|
277
|
+
'[--container-size SIZE_MB] [--disk-size SIZE_GB]',
|
225
278
|
'Restart a database'
|
226
279
|
option :environment
|
227
280
|
option :container_size, type: :numeric
|
281
|
+
option :disk_size, type: :numeric
|
228
282
|
option :size, type: :numeric
|
229
283
|
define_method 'db:restart' do |handle|
|
230
284
|
database = ensure_database(options.merge(db: handle))
|
@@ -232,9 +286,15 @@ module Aptible
|
|
232
286
|
opts = {
|
233
287
|
type: 'restart',
|
234
288
|
container_size: options[:container_size],
|
235
|
-
disk_size: options[:size]
|
289
|
+
disk_size: options[:disk_size] || options[:size]
|
236
290
|
}.delete_if { |_, v| v.nil? }
|
237
291
|
|
292
|
+
CLI.logger.warn([
|
293
|
+
'You have used the "--size" option to specify a disk size.',
|
294
|
+
'This option which be deprecated in a future version.',
|
295
|
+
'Please use the "--disk-size" option, instead.'
|
296
|
+
].join("\n")) if options[:size]
|
297
|
+
|
238
298
|
CLI.logger.info "Restarting #{database.handle}..."
|
239
299
|
op = database.create_operation!(opts)
|
240
300
|
attach_to_operation_logs(op)
|
@@ -19,7 +19,7 @@ module Aptible
|
|
19
19
|
raise "Invalid scheme: #{uri.scheme} (use https)"
|
20
20
|
end
|
21
21
|
|
22
|
-
apis = [Aptible::Auth, Aptible::Api
|
22
|
+
apis = [Aptible::Auth, Aptible::Api]
|
23
23
|
|
24
24
|
api = apis.find do |klass|
|
25
25
|
uri.host == URI(klass.configuration.root_url).host
|
data/lib/aptible/cli/version.rb
CHANGED
@@ -30,10 +30,15 @@ describe Aptible::CLI::Agent do
|
|
30
30
|
let(:created_at) { Time.now }
|
31
31
|
let(:expires_at) { created_at + 1.week }
|
32
32
|
|
33
|
-
|
34
|
-
|
35
|
-
|
33
|
+
def make_oauth2_error(code, ctx = nil)
|
34
|
+
parsed = { 'error' => code }
|
35
|
+
parsed['exception_context'] = ctx if ctx
|
36
|
+
response = double('response', parsed: parsed, body: "error #{code}")
|
37
|
+
allow(response).to receive(:error=)
|
38
|
+
OAuth2::Error.new(response)
|
39
|
+
end
|
36
40
|
|
41
|
+
before do
|
37
42
|
allow(token).to receive(:access_token).and_return 'access_token'
|
38
43
|
allow(token).to receive(:created_at).and_return created_at
|
39
44
|
allow(token).to receive(:expires_at).and_return expires_at
|
@@ -55,7 +60,7 @@ describe Aptible::CLI::Agent do
|
|
55
60
|
|
56
61
|
it 'should raise an error if authentication fails' do
|
57
62
|
allow(Aptible::Auth::Token).to receive(:create)
|
58
|
-
.and_raise(
|
63
|
+
.and_raise(make_oauth2_error('foo'))
|
59
64
|
expect do
|
60
65
|
subject.login
|
61
66
|
end.to raise_error 'Could not authenticate with given credentials: foo'
|
@@ -87,7 +92,7 @@ describe Aptible::CLI::Agent do
|
|
87
92
|
expect { subject.login }.to raise_error(/Invalid token lifetime/)
|
88
93
|
end
|
89
94
|
|
90
|
-
context 'with
|
95
|
+
context 'with 2FA' do
|
91
96
|
let(:email) { 'foo@example.org' }
|
92
97
|
let(:password) { 'bar' }
|
93
98
|
let(:token) { '123456' }
|
@@ -124,7 +129,7 @@ describe Aptible::CLI::Agent do
|
|
124
129
|
expect(Aptible::Auth::Token).to receive(:create)
|
125
130
|
.with(email: email, password: password, expires_in: 1.week.seconds)
|
126
131
|
.once
|
127
|
-
.and_raise(
|
132
|
+
.and_raise(make_oauth2_error('otp_token_required'))
|
128
133
|
|
129
134
|
expect(Aptible::Auth::Token).to receive(:create)
|
130
135
|
.with(email: email, password: password, otp_token: token,
|
@@ -139,7 +144,7 @@ describe Aptible::CLI::Agent do
|
|
139
144
|
expect(Aptible::Auth::Token).to receive(:create)
|
140
145
|
.with(email: email, password: password, expires_in: 1.day.seconds)
|
141
146
|
.once
|
142
|
-
.and_raise(
|
147
|
+
.and_raise(make_oauth2_error('otp_token_required'))
|
143
148
|
|
144
149
|
expect(Aptible::Auth::Token).to receive(:create)
|
145
150
|
.with(email: email, password: password, otp_token: token,
|
@@ -155,17 +160,134 @@ describe Aptible::CLI::Agent do
|
|
155
160
|
expect(Aptible::Auth::Token).to receive(:create)
|
156
161
|
.with(email: email, password: password, expires_in: 1.week.seconds)
|
157
162
|
.once
|
158
|
-
.and_raise(
|
163
|
+
.and_raise(make_oauth2_error('otp_token_required'))
|
159
164
|
|
160
165
|
expect(Aptible::Auth::Token).to receive(:create)
|
161
166
|
.with(email: email, password: password, otp_token: token,
|
162
167
|
expires_in: 12.hours.seconds)
|
163
168
|
.once
|
164
|
-
.and_raise(
|
169
|
+
.and_raise(make_oauth2_error('foo'))
|
165
170
|
|
166
171
|
expect { subject.login }.to raise_error(/Could not authenticate/)
|
167
172
|
end
|
168
173
|
end
|
174
|
+
|
175
|
+
context 'with U2F' do
|
176
|
+
before do
|
177
|
+
allow(subject).to receive(:options)
|
178
|
+
.and_return(email: email, password: password)
|
179
|
+
end
|
180
|
+
|
181
|
+
it 'shouldn\'t use U2F if not supported' do
|
182
|
+
allow(subject).to receive(:which)
|
183
|
+
.and_return(nil)
|
184
|
+
|
185
|
+
e = make_oauth2_error(
|
186
|
+
'otp_token_required',
|
187
|
+
'u2f' => {
|
188
|
+
'challenge' => 'some 123',
|
189
|
+
'devices' => [
|
190
|
+
{ 'version' => 'U2F_V2', 'key_handle' => '123' },
|
191
|
+
{ 'version' => 'U2F_V2', 'key_handle' => '456' }
|
192
|
+
]
|
193
|
+
}
|
194
|
+
)
|
195
|
+
|
196
|
+
expect(Aptible::Auth::Token).to receive(:create)
|
197
|
+
.with(email: email, password: password, expires_in: 1.week.seconds)
|
198
|
+
.once
|
199
|
+
.and_raise(e)
|
200
|
+
|
201
|
+
expect(Aptible::CLI::Helpers::SecurityKey).not_to \
|
202
|
+
receive(:authenticate)
|
203
|
+
|
204
|
+
expect(subject).to receive(:ask).with('2FA Token: ')
|
205
|
+
.once
|
206
|
+
.and_return(token)
|
207
|
+
|
208
|
+
expect(Aptible::Auth::Token).to receive(:create)
|
209
|
+
.with(email: email, password: password, otp_token: token,
|
210
|
+
expires_in: 12.hours.seconds)
|
211
|
+
.once
|
212
|
+
.and_return(token)
|
213
|
+
|
214
|
+
subject.login
|
215
|
+
end
|
216
|
+
|
217
|
+
it 'should call into U2F if supported' do
|
218
|
+
allow(subject).to receive(:which).and_return('u2f-host')
|
219
|
+
allow(subject).to receive(:ask).with('2FA Token: ') { sleep }
|
220
|
+
|
221
|
+
e = make_oauth2_error(
|
222
|
+
'otp_token_required',
|
223
|
+
'u2f' => {
|
224
|
+
'challenge' => 'some 123',
|
225
|
+
'devices' => [
|
226
|
+
{ 'version' => 'U2F_V2', 'key_handle' => '123' },
|
227
|
+
{ 'version' => 'U2F_V2', 'key_handle' => '456' }
|
228
|
+
]
|
229
|
+
}
|
230
|
+
)
|
231
|
+
|
232
|
+
u2f = double('u2f response')
|
233
|
+
|
234
|
+
expect(Aptible::Auth::Token).to receive(:create)
|
235
|
+
.with(email: email, password: password, expires_in: 1.week.seconds)
|
236
|
+
.once
|
237
|
+
.and_raise(e)
|
238
|
+
|
239
|
+
expect(subject).to receive(:puts).with(/security key/i)
|
240
|
+
|
241
|
+
expect(Aptible::CLI::Helpers::SecurityKey).to receive(:authenticate)
|
242
|
+
.with(
|
243
|
+
'https://auth.aptible.com/',
|
244
|
+
'https://auth.aptible.com/u2f/trusted_facets',
|
245
|
+
'some 123',
|
246
|
+
array_including(
|
247
|
+
instance_of(Aptible::CLI::Helpers::SecurityKey::Device),
|
248
|
+
instance_of(Aptible::CLI::Helpers::SecurityKey::Device)
|
249
|
+
)
|
250
|
+
).and_return(u2f)
|
251
|
+
|
252
|
+
expect(Aptible::Auth::Token).to receive(:create)
|
253
|
+
.with(email: email, password: password, u2f: u2f,
|
254
|
+
expires_in: 12.hours.seconds)
|
255
|
+
.once
|
256
|
+
.and_return(token)
|
257
|
+
|
258
|
+
subject.login
|
259
|
+
end
|
260
|
+
end
|
261
|
+
|
262
|
+
context 'SSO logins' do
|
263
|
+
let(:token) { 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzUxMiJ9.eyJpZCI6I' }
|
264
|
+
|
265
|
+
it 'accepts a token as an argument' do
|
266
|
+
options = { sso: token }
|
267
|
+
allow(subject).to receive(:options).and_return options
|
268
|
+
|
269
|
+
expect(subject).to receive(:save_token).with(token)
|
270
|
+
|
271
|
+
subject.login
|
272
|
+
end
|
273
|
+
|
274
|
+
it 'rejects clearly invalid tokens' do
|
275
|
+
options = { sso: 'blarg' }
|
276
|
+
allow(subject).to receive(:options).and_return options
|
277
|
+
|
278
|
+
expect { subject.login }.to raise_error Thor::Error
|
279
|
+
end
|
280
|
+
|
281
|
+
it 'prompts for a token if none provided' do
|
282
|
+
options = { sso: 'sso' }
|
283
|
+
allow(subject).to receive(:options).and_return options
|
284
|
+
|
285
|
+
expect(subject).to receive(:ask).once.and_return(token)
|
286
|
+
expect(subject).to receive(:save_token).with(token)
|
287
|
+
|
288
|
+
subject.login
|
289
|
+
end
|
290
|
+
end
|
169
291
|
end
|
170
292
|
end
|
171
293
|
|