opsicle 0.3.1 → 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.
@@ -89,6 +89,15 @@ opsicle ssh-key staging <key-file>
89
89
  opsicle monitor staging
90
90
 
91
91
  ```
92
+ ### Updating Custom Chef Recipes
93
+ ```bash
94
+ # Upload a cookbooks directory to S3 and update the stack's custom cookbooks
95
+ opsicle chef-update staging --bucket-name my-opsworks-cookbooks
96
+
97
+ ```
98
+ This command accepts a --path argument to the directory of cookbooks to upload. It defaults to 'cookbooks'.
99
+ It also accepts a --bucket-name for the base s3 bucket. This flag is required.
100
+
92
101
 
93
102
  Opsicle accepts a `--verbose` flag or the VERBOSE environment variable to show additional information as commands are run.
94
103
 
@@ -114,4 +114,19 @@ command 'opsworks-url' do |c|
114
114
  end
115
115
  end
116
116
 
117
+ desc "Update the Stack Custom Chef Cookbooks"
118
+ arg_name '<environment>'
119
+ command 'chef-update' do |c|
120
+ c.flag [:path],
121
+ :desc => "Path to the directory of chef cookbooks to be uploaded to s3",
122
+ :default_value => "cookbooks"
123
+ c.flag [:"bucket-name"],
124
+ :desc => "The S3 bucket name to upload the cookbooks to"
125
+ c.action do |global_options, options, args|
126
+ raise ArgumentError, "Environment is required" unless args.first
127
+
128
+ Opsicle::ChefUpdate.new(*args).execute global_options.merge(options)
129
+ end
130
+ end
131
+
117
132
  exit run(ARGV)
@@ -2,21 +2,23 @@ require 'opsicle/config'
2
2
 
3
3
  module Opsicle
4
4
  class Client
5
- attr_reader :aws_client
5
+ attr_reader :opsworks
6
+ attr_reader :s3
6
7
  attr_reader :config
7
8
 
8
9
  def initialize(environment)
9
10
  @config = Config.new(environment)
10
11
  @config.configure_aws!
11
- @aws_client = AWS::OpsWorks.new.client
12
+ @opsworks = AWS::OpsWorks.new.client
13
+ @s3 = AWS::S3.new
12
14
  end
13
15
 
14
16
  def run_command(command, options={})
15
- aws_client.create_deployment(command_options(command, options))
17
+ opsworks.create_deployment(command_options(command, options))
16
18
  end
17
19
 
18
20
  def api_call(command, options={})
19
- aws_client.public_send(command, options)
21
+ opsworks.public_send(command, options)
20
22
  end
21
23
 
22
24
  def opsworks_url
@@ -1,6 +1,7 @@
1
1
  require 'opsicle/client'
2
2
 
3
3
  require "opsicle/commands/deploy"
4
+ require "opsicle/commands/chef_update"
4
5
  require "opsicle/commands/list"
5
6
  require "opsicle/commands/ssh"
6
7
  require "opsicle/commands/ssh_key"
@@ -0,0 +1,54 @@
1
+ require 'opsicle/s3_bucket'
2
+ require 'zlib'
3
+ require 'archive/tar/minitar'
4
+
5
+ module Opsicle
6
+ class ChefUpdate
7
+ attr_reader :client
8
+ attr_reader :tar_file
9
+ attr_reader :stack
10
+
11
+ def initialize(environment)
12
+ @environment = environment
13
+ @client = Client.new(environment)
14
+ @stack = Stack.new(@client)
15
+ @tar_file = "#{@stack.name}.tgz"
16
+ end
17
+
18
+ def execute(options={ monitor: true })
19
+ tar_cookbooks(options[:path])
20
+ s3_upload(options[:"bucket-name"])
21
+ cleanup_tar
22
+ update_custom_cookbooks
23
+ launch_stack_monitor(options)
24
+ end
25
+
26
+ private
27
+
28
+ def tar_cookbooks(cookbooks_dir)
29
+ tgz = Zlib::GzipWriter.new(File.open(tar_file, 'wb'))
30
+ package = Dir[cookbooks_dir].entries.reject{ |entry| entry =~ /^\.\.?$/ }
31
+ Archive::Tar::Minitar.pack(package, tgz)
32
+ end
33
+
34
+ def s3_upload(bucket_name)
35
+ bucket = S3Bucket.new(@client, bucket_name)
36
+ bucket.update(tar_file)
37
+ end
38
+
39
+ def cleanup_tar
40
+ FileUtils.rm(tar_file)
41
+ end
42
+
43
+ def update_custom_cookbooks
44
+ Output.say "Starting OpsWorks Custom Cookboks Update..."
45
+ client.run_command('update_custom_cookbooks')
46
+ end
47
+
48
+ def launch_stack_monitor(options)
49
+ Output.say_verbose "Starting Stack Monitor..."
50
+ @monitor = Opsicle::Monitor::App.new(@environment, options)
51
+ @monitor.start
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,19 @@
1
+ require 'pathname'
2
+
3
+ module Opsicle
4
+ class S3Bucket
5
+ attr_reader :bucket
6
+
7
+ def initialize(client, bucket_name)
8
+ @bucket = client.s3.buckets[bucket_name]
9
+ raise UnknownBucket unless @bucket.exists?
10
+ end
11
+
12
+ def update(object)
13
+ obj = bucket.objects[object]
14
+ obj.write(Pathname.new(object))
15
+ end
16
+ end
17
+
18
+ UnknownBucket = Class.new(StandardError)
19
+ end
@@ -1,3 +1,3 @@
1
1
  module Opsicle
2
- VERSION = "0.3.1"
2
+ VERSION = "0.4.0"
3
3
  end
@@ -22,6 +22,7 @@ Gem::Specification.new do |spec|
22
22
  spec.add_dependency "gli"
23
23
  spec.add_dependency "highline"
24
24
  spec.add_dependency "terminal-table"
25
+ spec.add_dependency "minitar"
25
26
 
26
27
  spec.add_development_dependency "bundler", "~> 1.3"
27
28
  spec.add_development_dependency "rake"
@@ -0,0 +1,129 @@
1
+ require "spec_helper"
2
+ require "opsicle"
3
+ require 'archive/tar/minitar'
4
+
5
+ module Opsicle
6
+ describe ChefUpdate do
7
+ subject { ChefUpdate.new('derp') }
8
+ let(:monitor) { double(:start => nil) }
9
+ let(:client) { double }
10
+ let(:stack) { double(name: 'stack') }
11
+ let(:tar_file) { "stack.tgz" }
12
+ let(:bucket_name) { "cookbooks" }
13
+ let(:bucket) { double }
14
+ let(:cookbooks_dirname) { "cookbooks" }
15
+
16
+ before do
17
+ allow(Client).to receive(:new).with('derp').and_return(client)
18
+ allow(Stack).to receive(:new).with(client).and_return(stack)
19
+ allow(subject).to receive(:tar_file).and_return(tar_file)
20
+
21
+ allow(Monitor::App).to receive(:new).and_return(monitor)
22
+ allow(monitor).to receive(:start)
23
+
24
+ allow(Output).to receive(:say)
25
+ allow(Output).to receive(:say_verbose)
26
+ end
27
+
28
+ context "#execute" do
29
+
30
+ it "tars up the cookooks" do
31
+ allow(subject).to receive(:s3_upload)
32
+ allow(subject).to receive(:cleanup_tar)
33
+ allow(subject).to receive(:update_custom_cookbooks)
34
+ allow(subject).to receive(:launch_stack_monitor)
35
+ expect(subject).to receive(:tar_cookbooks)
36
+
37
+ subject.execute
38
+ end
39
+
40
+ it "uploads the cookbooks to s3" do
41
+ allow(subject).to receive(:tar_cookbooks)
42
+ allow(subject).to receive(:cleanup_tar)
43
+ allow(subject).to receive(:update_custom_cookbooks)
44
+ allow(subject).to receive(:launch_stack_monitor)
45
+ expect(subject).to receive(:s3_upload)
46
+
47
+ subject.execute
48
+ end
49
+
50
+ it "cleans up the tarball created to upload to s3" do
51
+ allow(subject).to receive(:tar_cookbooks)
52
+ allow(subject).to receive(:s3_upload)
53
+ allow(subject).to receive(:update_custom_cookbooks)
54
+ allow(subject).to receive(:launch_stack_monitor)
55
+ expect(subject).to receive(:cleanup_tar)
56
+
57
+ subject.execute
58
+ end
59
+
60
+ it "creates a new update_custom_cookbooks and opens stack monitor" do
61
+ allow(subject).to receive(:tar_cookbooks)
62
+ allow(subject).to receive(:s3_upload)
63
+ allow(subject).to receive(:cleanup_tar)
64
+ allow(subject).to receive(:launch_stack_monitor)
65
+ expect(subject).to receive(:update_custom_cookbooks)
66
+
67
+ subject.execute
68
+ end
69
+
70
+ it "starts the stack monitor" do
71
+ allow(subject).to receive(:tar_cookbooks)
72
+ allow(subject).to receive(:s3_upload)
73
+ allow(subject).to receive(:cleanup_tar)
74
+ allow(subject).to receive(:update_custom_cookbooks)
75
+ expect(subject).to receive(:launch_stack_monitor)
76
+
77
+ subject.execute
78
+ end
79
+ end
80
+
81
+ context "#tar_cookbooks" do
82
+ let(:tar_file_handle) { double }
83
+ let(:cookbooks_dir) { double }
84
+ let(:gzip) { double }
85
+ let(:entries) { %w(recipes templates files) }
86
+ it "tarballs up the cookbooks directory" do
87
+ expect(File).to receive(:open).with(tar_file, 'wb').and_return(tar_file_handle)
88
+ expect(Zlib::GzipWriter).to receive(:new).with(tar_file_handle).and_return(gzip)
89
+ expect(Dir).to receive(:"[]").with(cookbooks_dirname).and_return(cookbooks_dir)
90
+ expect(cookbooks_dir).to receive(:entries).and_return(entries)
91
+ expect(Archive::Tar::Minitar).to receive(:pack).with(entries, gzip)
92
+ subject.send :tar_cookbooks, cookbooks_dirname
93
+ end
94
+ end
95
+
96
+ context "#s3_upload" do
97
+ it "updates the s3 bucket with the tarballed cookbooks" do
98
+ expect(S3Bucket).to receive(:new).with(client, bucket_name).and_return(bucket)
99
+ expect(bucket).to receive(:update).with(tar_file)
100
+ subject.send :s3_upload, bucket_name
101
+ end
102
+ end
103
+
104
+ context "#cleanup_tar" do
105
+ it "removes the tar file now that the upload is complete" do
106
+ expect(FileUtils).to receive(:rm).with(tar_file)
107
+ subject.send :cleanup_tar
108
+ end
109
+ end
110
+
111
+ context "#update_custom_cookbooks" do
112
+ it "runs the aws update custom cookbooks command" do
113
+ expect(client).to receive(:run_command).with('update_custom_cookbooks')
114
+
115
+ subject.send :update_custom_cookbooks
116
+ end
117
+ end
118
+
119
+ context "#launch_stack_monitor" do
120
+ let(:options) { { derp: 'herp' } }
121
+ it "launches the opsicle stack monitor" do
122
+
123
+ expect(Monitor::App).to receive(:new).with('derp', options)
124
+
125
+ subject.send :launch_stack_monitor, options
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,51 @@
1
+ require "spec_helper"
2
+ require "opsicle"
3
+
4
+ module Opsicle
5
+ describe S3Bucket do
6
+ subject { S3Bucket.new(client, bucket_name) }
7
+ let(:bucket_name) { "bucket" }
8
+ let(:bucket) { double(exists?: true) }
9
+ let(:buckets) { double(:"[]" => bucket) }
10
+ let(:s3) { double(buckets: buckets) }
11
+ let(:client) { double(s3: s3) }
12
+
13
+ context "#new" do
14
+ subject { S3Bucket }
15
+ it "finds the bucket from s3" do
16
+ expect(client).to receive(:s3).and_return(s3)
17
+ expect(s3).to receive(:buckets).and_return(buckets)
18
+ expect(buckets).to receive(:"[]").with(bucket_name).and_return(bucket)
19
+ subject.new(client, bucket_name)
20
+ end
21
+
22
+ it "throws an error if the bucket can't be found" do
23
+ allow(bucket).to receive(:exists?).and_return(false)
24
+ expect(client).to receive(:s3).and_return(s3)
25
+ expect(s3).to receive(:buckets).and_return(buckets)
26
+ expect(buckets).to receive(:"[]").with(bucket_name).and_return(bucket)
27
+ expect { subject.new(client, bucket_name) }.to raise_error(UnknownBucket)
28
+ end
29
+ end
30
+
31
+ context "#update" do
32
+ let(:object) { double }
33
+ let(:objects) { double(:"[]" => object) }
34
+ before do
35
+ allow(Pathname).to receive(:new)
36
+ end
37
+
38
+ it "finds the object in the s3 bucket" do
39
+ allow(object).to receive(:write)
40
+ expect(bucket).to receive(:objects).and_return(objects)
41
+ subject.update(object)
42
+ end
43
+
44
+ it "writes the new object in the s3 bucket" do
45
+ allow(bucket).to receive(:objects).and_return(objects)
46
+ expect(object).to receive(:write)
47
+ subject.update(object)
48
+ end
49
+ end
50
+ end
51
+ end
metadata CHANGED
@@ -1,18 +1,20 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: opsicle
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.4.0
5
+ prerelease:
5
6
  platform: ruby
6
7
  authors:
7
8
  - Andy Fleener
8
9
  autorequire:
9
10
  bindir: bin
10
11
  cert_chain: []
11
- date: 2014-03-07 00:00:00.000000000 Z
12
+ date: 2014-03-21 00:00:00.000000000 Z
12
13
  dependencies:
13
14
  - !ruby/object:Gem::Dependency
14
15
  name: aws-sdk
15
16
  requirement: !ruby/object:Gem::Requirement
17
+ none: false
16
18
  requirements:
17
19
  - - ~>
18
20
  - !ruby/object:Gem::Version
@@ -20,6 +22,7 @@ dependencies:
20
22
  type: :runtime
21
23
  prerelease: false
22
24
  version_requirements: !ruby/object:Gem::Requirement
25
+ none: false
23
26
  requirements:
24
27
  - - ~>
25
28
  - !ruby/object:Gem::Version
@@ -27,6 +30,7 @@ dependencies:
27
30
  - !ruby/object:Gem::Dependency
28
31
  name: gli
29
32
  requirement: !ruby/object:Gem::Requirement
33
+ none: false
30
34
  requirements:
31
35
  - - ! '>='
32
36
  - !ruby/object:Gem::Version
@@ -34,6 +38,7 @@ dependencies:
34
38
  type: :runtime
35
39
  prerelease: false
36
40
  version_requirements: !ruby/object:Gem::Requirement
41
+ none: false
37
42
  requirements:
38
43
  - - ! '>='
39
44
  - !ruby/object:Gem::Version
@@ -41,6 +46,7 @@ dependencies:
41
46
  - !ruby/object:Gem::Dependency
42
47
  name: highline
43
48
  requirement: !ruby/object:Gem::Requirement
49
+ none: false
44
50
  requirements:
45
51
  - - ! '>='
46
52
  - !ruby/object:Gem::Version
@@ -48,6 +54,7 @@ dependencies:
48
54
  type: :runtime
49
55
  prerelease: false
50
56
  version_requirements: !ruby/object:Gem::Requirement
57
+ none: false
51
58
  requirements:
52
59
  - - ! '>='
53
60
  - !ruby/object:Gem::Version
@@ -55,6 +62,7 @@ dependencies:
55
62
  - !ruby/object:Gem::Dependency
56
63
  name: terminal-table
57
64
  requirement: !ruby/object:Gem::Requirement
65
+ none: false
58
66
  requirements:
59
67
  - - ! '>='
60
68
  - !ruby/object:Gem::Version
@@ -62,6 +70,23 @@ dependencies:
62
70
  type: :runtime
63
71
  prerelease: false
64
72
  version_requirements: !ruby/object:Gem::Requirement
73
+ none: false
74
+ requirements:
75
+ - - ! '>='
76
+ - !ruby/object:Gem::Version
77
+ version: '0'
78
+ - !ruby/object:Gem::Dependency
79
+ name: minitar
80
+ requirement: !ruby/object:Gem::Requirement
81
+ none: false
82
+ requirements:
83
+ - - ! '>='
84
+ - !ruby/object:Gem::Version
85
+ version: '0'
86
+ type: :runtime
87
+ prerelease: false
88
+ version_requirements: !ruby/object:Gem::Requirement
89
+ none: false
65
90
  requirements:
66
91
  - - ! '>='
67
92
  - !ruby/object:Gem::Version
@@ -69,6 +94,7 @@ dependencies:
69
94
  - !ruby/object:Gem::Dependency
70
95
  name: bundler
71
96
  requirement: !ruby/object:Gem::Requirement
97
+ none: false
72
98
  requirements:
73
99
  - - ~>
74
100
  - !ruby/object:Gem::Version
@@ -76,6 +102,7 @@ dependencies:
76
102
  type: :development
77
103
  prerelease: false
78
104
  version_requirements: !ruby/object:Gem::Requirement
105
+ none: false
79
106
  requirements:
80
107
  - - ~>
81
108
  - !ruby/object:Gem::Version
@@ -83,6 +110,7 @@ dependencies:
83
110
  - !ruby/object:Gem::Dependency
84
111
  name: rake
85
112
  requirement: !ruby/object:Gem::Requirement
113
+ none: false
86
114
  requirements:
87
115
  - - ! '>='
88
116
  - !ruby/object:Gem::Version
@@ -90,6 +118,7 @@ dependencies:
90
118
  type: :development
91
119
  prerelease: false
92
120
  version_requirements: !ruby/object:Gem::Requirement
121
+ none: false
93
122
  requirements:
94
123
  - - ! '>='
95
124
  - !ruby/object:Gem::Version
@@ -97,6 +126,7 @@ dependencies:
97
126
  - !ruby/object:Gem::Dependency
98
127
  name: rspec
99
128
  requirement: !ruby/object:Gem::Requirement
129
+ none: false
100
130
  requirements:
101
131
  - - '='
102
132
  - !ruby/object:Gem::Version
@@ -104,6 +134,7 @@ dependencies:
104
134
  type: :development
105
135
  prerelease: false
106
136
  version_requirements: !ruby/object:Gem::Requirement
137
+ none: false
107
138
  requirements:
108
139
  - - '='
109
140
  - !ruby/object:Gem::Version
@@ -111,6 +142,7 @@ dependencies:
111
142
  - !ruby/object:Gem::Dependency
112
143
  name: guard
113
144
  requirement: !ruby/object:Gem::Requirement
145
+ none: false
114
146
  requirements:
115
147
  - - ! '>='
116
148
  - !ruby/object:Gem::Version
@@ -118,6 +150,7 @@ dependencies:
118
150
  type: :development
119
151
  prerelease: false
120
152
  version_requirements: !ruby/object:Gem::Requirement
153
+ none: false
121
154
  requirements:
122
155
  - - ! '>='
123
156
  - !ruby/object:Gem::Version
@@ -125,6 +158,7 @@ dependencies:
125
158
  - !ruby/object:Gem::Dependency
126
159
  name: guard-rspec
127
160
  requirement: !ruby/object:Gem::Requirement
161
+ none: false
128
162
  requirements:
129
163
  - - ! '>='
130
164
  - !ruby/object:Gem::Version
@@ -132,6 +166,7 @@ dependencies:
132
166
  type: :development
133
167
  prerelease: false
134
168
  version_requirements: !ruby/object:Gem::Requirement
169
+ none: false
135
170
  requirements:
136
171
  - - ! '>='
137
172
  - !ruby/object:Gem::Version
@@ -158,6 +193,7 @@ files:
158
193
  - lib/opsicle.rb
159
194
  - lib/opsicle/client.rb
160
195
  - lib/opsicle/commands.rb
196
+ - lib/opsicle/commands/chef_update.rb
161
197
  - lib/opsicle/commands/deploy.rb
162
198
  - lib/opsicle/commands/list.rb
163
199
  - lib/opsicle/commands/ssh.rb
@@ -177,10 +213,12 @@ files:
177
213
  - lib/opsicle/monitor/subpanel.rb
178
214
  - lib/opsicle/monitor/translatable.rb
179
215
  - lib/opsicle/output.rb
216
+ - lib/opsicle/s3_bucket.rb
180
217
  - lib/opsicle/stack.rb
181
218
  - lib/opsicle/version.rb
182
219
  - opsicle.gemspec
183
220
  - spec/opsicle/client_spec.rb
221
+ - spec/opsicle/commands/chef_update_spec.rb
184
222
  - spec/opsicle/commands/deploy_spec.rb
185
223
  - spec/opsicle/commands/list_spec.rb
186
224
  - spec/opsicle/commands/ssh_key_spec.rb
@@ -191,33 +229,42 @@ files:
191
229
  - spec/opsicle/monitor/screen_spec.rb
192
230
  - spec/opsicle/monitor/spy/deployments_spec.rb
193
231
  - spec/opsicle/monitor/subpanel_spec.rb
232
+ - spec/opsicle/s3_bucket_spec.rb
194
233
  - spec/spec_helper.rb
195
234
  homepage: https://github.com/sportngin/opsicle
196
235
  licenses:
197
236
  - MIT
198
- metadata: {}
199
237
  post_install_message:
200
238
  rdoc_options: []
201
239
  require_paths:
202
240
  - lib
203
241
  required_ruby_version: !ruby/object:Gem::Requirement
242
+ none: false
204
243
  requirements:
205
244
  - - ! '>='
206
245
  - !ruby/object:Gem::Version
207
246
  version: '0'
247
+ segments:
248
+ - 0
249
+ hash: -1300190123240828980
208
250
  required_rubygems_version: !ruby/object:Gem::Requirement
251
+ none: false
209
252
  requirements:
210
253
  - - ! '>='
211
254
  - !ruby/object:Gem::Version
212
255
  version: '0'
256
+ segments:
257
+ - 0
258
+ hash: -1300190123240828980
213
259
  requirements: []
214
260
  rubyforge_project:
215
- rubygems_version: 2.2.1
261
+ rubygems_version: 1.8.25
216
262
  signing_key:
217
- specification_version: 4
263
+ specification_version: 3
218
264
  summary: An opsworks specific abstraction on top of the aws sdk
219
265
  test_files:
220
266
  - spec/opsicle/client_spec.rb
267
+ - spec/opsicle/commands/chef_update_spec.rb
221
268
  - spec/opsicle/commands/deploy_spec.rb
222
269
  - spec/opsicle/commands/list_spec.rb
223
270
  - spec/opsicle/commands/ssh_key_spec.rb
@@ -228,4 +275,5 @@ test_files:
228
275
  - spec/opsicle/monitor/screen_spec.rb
229
276
  - spec/opsicle/monitor/spy/deployments_spec.rb
230
277
  - spec/opsicle/monitor/subpanel_spec.rb
278
+ - spec/opsicle/s3_bucket_spec.rb
231
279
  - spec/spec_helper.rb
checksums.yaml DELETED
@@ -1,15 +0,0 @@
1
- ---
2
- !binary "U0hBMQ==":
3
- metadata.gz: !binary |-
4
- NWMyN2Q2YjBkN2FiMTgwNjhjZjg2N2IwZTI3ZGVkYTFjYTdjY2NlMQ==
5
- data.tar.gz: !binary |-
6
- YzJhOGM3MzMwNDBmNDczMTcwYWRkNjFlYTM2NDJmNjVhMWY4OTM4Ng==
7
- SHA512:
8
- metadata.gz: !binary |-
9
- ZTRjZDg3ZjQ4MjQwYTBkZDEzOWZjMTE4ZWU5MGE1NWE1ZTI2OTg0ZjFhNGQ4
10
- ZGQ4MmUwMzJjMzllZDc4Y2M4NjE0YzI3MzA0MzM2ODBjYWFjZGJmMDYwYWE2
11
- YWQ0Y2EwMzI4ZDdjY2NjNTEyZTFlYjM1MTFlYjQ3Mjc3Yjc5YzQ=
12
- data.tar.gz: !binary |-
13
- Y2I5OWMyMTI1YjhkMjg4NjczYjc4YzU5OTQ0MGQ3YzI1MDY3ZDAzY2NmYTkx
14
- MDk3MTc3NzI4NzJiMDUwZWU4MDg1NzRkNTcyODM1MDZjMDY3ODI4NGI1ZWJi
15
- MWQ1MWQyZmNlNmE0YmE0MWEzNzdkOGM5NGMzOWRiMDY1ZjM0MTI=