aptible-cli 0.16.2 → 0.16.7
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
|