activestorage_openstack 0.1.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +30 -0
- data/Rakefile +19 -0
- data/config/spring.rb +3 -0
- data/lib/active_storage/openstack/railtie.rb +9 -0
- data/lib/active_storage/openstack/version.rb +7 -0
- data/lib/active_storage/service/openstack_service.rb +141 -0
- data/lib/activestorage_openstack.rb +6 -0
- data/lib/openstack/client.rb +38 -0
- data/lib/openstack/client/authenticator.rb +82 -0
- data/lib/openstack/client/authenticator/request.rb +67 -0
- data/lib/openstack/client/storage.rb +126 -0
- data/lib/openstack/client/storage/bulk_delete_objects.rb +36 -0
- data/lib/openstack/client/storage/create_temporary_uri.rb +70 -0
- data/lib/openstack/client/storage/delete_object.rb +23 -0
- data/lib/openstack/client/storage/get_object.rb +24 -0
- data/lib/openstack/client/storage/get_object_by_range.rb +45 -0
- data/lib/openstack/client/storage/list_objects.rb +33 -0
- data/lib/openstack/client/storage/object_store_url.rb +49 -0
- data/lib/openstack/client/storage/put_object.rb +53 -0
- data/lib/openstack/client/storage/show_object_metadata.rb +24 -0
- data/lib/openstack/helpers/cache_readerable.rb +29 -0
- data/lib/openstack/helpers/cacheable_body.rb +42 -0
- data/lib/openstack/helpers/https_client.rb +15 -0
- data/lib/support/zeitwerk.rb +29 -0
- metadata +152 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 6938f92c6bab2b3a685785a1f9327d45698cdb7ee4c37a19f9919b6fa4beb714
|
4
|
+
data.tar.gz: 1d06b1f5924d3e91c41827d6aea85db39c4f7f049571f040b976bd787adfa8d5
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: bdf19b0ed23b27be6d10191dcea2d7966f425d67dd664ba52464349b3427514469ba9ef3becf6f84e3888780cb0d4758a9c90db92435f081aa3312bcc9094d7c
|
7
|
+
data.tar.gz: 3a7bc526129a78ab0323ea7ff0851c7294000ad2f5bd02759cf0cbe562356b8cbe1165bb627403199fb6569551cb620807750208b6416a8f3d5ec1ceae6b7e8e
|
data/MIT-LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright 2018
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,30 @@
|
|
1
|
+
[![Maintainability](https://api.codeclimate.com/v1/badges/75b77a2b9d9b42496264/maintainability)](https://codeclimate.com/github/argus-api-team/activestorage-openstack/maintainability) [![Test Coverage](https://api.codeclimate.com/v1/badges/75b77a2b9d9b42496264/test_coverage)](https://codeclimate.com/github/argus-api-team/activestorage-openstack/test_coverage) [![Build Status](https://travis-ci.org/argus-api-team/activestorage-openstack.svg?branch=master)](https://travis-ci.org/argus-api-team/activestorage-openstack)
|
2
|
+
|
3
|
+
# Activestorage::Openstack
|
4
|
+
Short description and motivation.
|
5
|
+
|
6
|
+
## Usage
|
7
|
+
How to use my plugin.
|
8
|
+
|
9
|
+
## Installation
|
10
|
+
Add this line to your application's Gemfile:
|
11
|
+
|
12
|
+
```ruby
|
13
|
+
gem 'activestorage_openstack'
|
14
|
+
```
|
15
|
+
|
16
|
+
And then execute:
|
17
|
+
```bash
|
18
|
+
$ bundle
|
19
|
+
```
|
20
|
+
|
21
|
+
Or install it yourself as:
|
22
|
+
```bash
|
23
|
+
$ gem install activestorage_openstack
|
24
|
+
```
|
25
|
+
|
26
|
+
## Contributing
|
27
|
+
Contribution directions go here.
|
28
|
+
|
29
|
+
## License
|
30
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
begin
|
4
|
+
require 'bundler/setup'
|
5
|
+
rescue LoadError
|
6
|
+
puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
|
7
|
+
end
|
8
|
+
|
9
|
+
require 'rdoc/task'
|
10
|
+
|
11
|
+
RDoc::Task.new(:rdoc) do |rdoc|
|
12
|
+
rdoc.rdoc_dir = 'rdoc'
|
13
|
+
rdoc.title = 'Activestorage::Openstack'
|
14
|
+
rdoc.options << '--line-numbers'
|
15
|
+
rdoc.rdoc_files.include('README.md')
|
16
|
+
rdoc.rdoc_files.include('lib/**/*.rb')
|
17
|
+
end
|
18
|
+
|
19
|
+
require 'bundler/gem_tasks'
|
data/config/spring.rb
ADDED
@@ -0,0 +1,141 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveStorage
|
4
|
+
class Service
|
5
|
+
# Wraps OpenStack Object Storage Service as an Active Storage service.
|
6
|
+
class OpenstackService < Service
|
7
|
+
attr_reader :config, :credentials, :storage, :client
|
8
|
+
|
9
|
+
def initialize(credentials:, container:, region:, **config)
|
10
|
+
@config = config
|
11
|
+
@credentials = credentials
|
12
|
+
@client = ::Openstack::Client.new username: credentials.fetch(:username),
|
13
|
+
password: credentials.fetch(:api_key)
|
14
|
+
@storage = client.storage container: container,
|
15
|
+
region: region
|
16
|
+
end
|
17
|
+
|
18
|
+
# :reek:LongParameterList
|
19
|
+
def upload(key, io, checksum: nil, **_options)
|
20
|
+
instrument :upload, key: key, checksum: checksum do
|
21
|
+
handle_errors do
|
22
|
+
storage.put_object(key, io, checksum: checksum)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
# :reek:UnusedParameters
|
28
|
+
def update_metadata(key, **metadata); end
|
29
|
+
|
30
|
+
def download(key, &block)
|
31
|
+
raise ActiveStorage::FileNotFoundError unless exist?(key)
|
32
|
+
|
33
|
+
if block_given?
|
34
|
+
instrument :streaming_download, key: key do
|
35
|
+
stream(key, &block)
|
36
|
+
end
|
37
|
+
else
|
38
|
+
instrument :download, key: key do
|
39
|
+
storage.get_object(key).body
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def download_chunk(key, range)
|
45
|
+
raise ActiveStorage::FileNotFoundError unless exist?(key)
|
46
|
+
|
47
|
+
instrument :download_chunk, key: key, range: range do
|
48
|
+
storage.get_object_by_range(key, range).body
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def delete(key)
|
53
|
+
instrument :delete, key: key do
|
54
|
+
storage.delete_object(key)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def delete_prefixed(prefix)
|
59
|
+
instrument :delete_prefixed, prefix: prefix do
|
60
|
+
keys = JSON.parse(
|
61
|
+
storage.list_objects(prefix: prefix).body
|
62
|
+
).map { |object| "/#{storage.container}/#{object.fetch('name')}" }
|
63
|
+
|
64
|
+
storage.bulk_delete_objects(keys)
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
def exist?(key)
|
69
|
+
instrument :exist, key: key do |payload|
|
70
|
+
payload[:exist] = storage.show_object_metadata(key).is_a?(Net::HTTPOK)
|
71
|
+
payload.fetch(:exist)
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
# :reek:LongParameterList
|
76
|
+
# :reek:UnusedParameters
|
77
|
+
# rubocop:disable Lint/UnusedMethodArgument
|
78
|
+
def url(key, expires_in:, disposition:, filename:, content_type:)
|
79
|
+
instrument :url, key: key do |payload|
|
80
|
+
payload[:url] = storage.temporary_url(
|
81
|
+
key,
|
82
|
+
'GET',
|
83
|
+
expires_in: expires_in,
|
84
|
+
disposition: disposition,
|
85
|
+
filename: filename
|
86
|
+
)
|
87
|
+
payload.fetch(:url)
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
# :reek:LongParameterList
|
92
|
+
def url_for_direct_upload(key, expires_in:, filename:, **_options)
|
93
|
+
instrument :url, key: key do |payload|
|
94
|
+
payload[:url] = storage.temporary_url(
|
95
|
+
key,
|
96
|
+
'PUT',
|
97
|
+
expires_in: expires_in,
|
98
|
+
filename: filename
|
99
|
+
)
|
100
|
+
payload.fetch(:url)
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
# :reek:LongParameterList
|
105
|
+
# :reek:UnusedParameters
|
106
|
+
# rubocop:disable Metrics/LineLength
|
107
|
+
def headers_for_direct_upload(key, filename:, content_type:, content_length:, checksum:)
|
108
|
+
{}
|
109
|
+
end
|
110
|
+
# rubocop:enable Metrics/LineLength
|
111
|
+
# rubocop:enable Lint/UnusedMethodArgument
|
112
|
+
|
113
|
+
private
|
114
|
+
|
115
|
+
def handle_errors
|
116
|
+
return unless block_given?
|
117
|
+
|
118
|
+
yield.tap do |request|
|
119
|
+
raise ActiveStorage::IntegrityError if request.is_a?(
|
120
|
+
Net::HTTPUnprocessableEntity
|
121
|
+
)
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
# Reads the file for the given key in chunks, yielding each to the block.
|
126
|
+
def stream(key, chunk_size: 5.megabytes)
|
127
|
+
blob = storage.show_object_metadata(key)
|
128
|
+
offset = 0
|
129
|
+
|
130
|
+
raise ActiveStorage::FileNotFoundError unless blob.present?
|
131
|
+
|
132
|
+
while offset < Integer(blob.fetch('Content-Length'))
|
133
|
+
yield storage.get_object_by_range(
|
134
|
+
key, offset..(offset + chunk_size - 1)
|
135
|
+
).body
|
136
|
+
offset += chunk_size
|
137
|
+
end
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Openstack
|
4
|
+
# Defines OpenStack client behaviours.
|
5
|
+
class Client
|
6
|
+
include ActiveModel::Model
|
7
|
+
|
8
|
+
attr_reader :username, :password, :cache
|
9
|
+
|
10
|
+
validates :username,
|
11
|
+
:password,
|
12
|
+
presence: true
|
13
|
+
|
14
|
+
delegate :authenticate, :authenticate_request, to: :authenticator
|
15
|
+
|
16
|
+
def initialize(username:, password:, cache: Rails.cache)
|
17
|
+
@username = username
|
18
|
+
@password = password
|
19
|
+
@cache = cache
|
20
|
+
end
|
21
|
+
|
22
|
+
def authenticator
|
23
|
+
@authenticator ||= Authenticator.new(
|
24
|
+
username: username,
|
25
|
+
password: password,
|
26
|
+
cache: cache
|
27
|
+
)
|
28
|
+
end
|
29
|
+
|
30
|
+
def storage(container:, region:)
|
31
|
+
@storage ||= Storage.new(
|
32
|
+
authenticator: authenticator,
|
33
|
+
container: container,
|
34
|
+
region: region
|
35
|
+
)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Openstack
|
4
|
+
class Client
|
5
|
+
# It retrieves token from OpenStack API and caches it.
|
6
|
+
class Authenticator
|
7
|
+
include ActiveModel::Model
|
8
|
+
include Helpers::CacheReaderable
|
9
|
+
|
10
|
+
attr_reader :cache,
|
11
|
+
:password,
|
12
|
+
:uri,
|
13
|
+
:username
|
14
|
+
|
15
|
+
validates :password,
|
16
|
+
:username,
|
17
|
+
presence: true
|
18
|
+
|
19
|
+
def initialize(
|
20
|
+
username:,
|
21
|
+
password:,
|
22
|
+
uri: Rails.application.config.x.openstack.fetch(:authentication_url),
|
23
|
+
cache: Rails.cache
|
24
|
+
)
|
25
|
+
@username = username
|
26
|
+
@password = password
|
27
|
+
@uri = URI(uri)
|
28
|
+
@cache = cache
|
29
|
+
end
|
30
|
+
|
31
|
+
def authenticate
|
32
|
+
cache_response if token_expired?
|
33
|
+
authentication_succeed?
|
34
|
+
end
|
35
|
+
|
36
|
+
def authenticate_request(&_request)
|
37
|
+
return unless block_given?
|
38
|
+
|
39
|
+
authenticate
|
40
|
+
yield.tap do |request|
|
41
|
+
request.add_field('x-auth-token', token)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def token
|
46
|
+
read_from_cache.fetch('token')
|
47
|
+
end
|
48
|
+
|
49
|
+
private
|
50
|
+
|
51
|
+
def cache_response
|
52
|
+
cache.write(cache_key, request.body_to_cache)
|
53
|
+
end
|
54
|
+
|
55
|
+
def token_expired?
|
56
|
+
read_from_cache.fetch('expires_at') < Time.now
|
57
|
+
rescue TypeError, NoMethodError
|
58
|
+
true
|
59
|
+
end
|
60
|
+
|
61
|
+
def authentication_succeed?
|
62
|
+
case read_from_cache.fetch('code')
|
63
|
+
when 201
|
64
|
+
true
|
65
|
+
else
|
66
|
+
false
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def request
|
71
|
+
@request ||= Request.new(
|
72
|
+
credentials: credentials,
|
73
|
+
uri: uri
|
74
|
+
).call.extend(Helpers::CacheableBody)
|
75
|
+
end
|
76
|
+
|
77
|
+
def credentials
|
78
|
+
OpenStruct.new(username: username, password: password)
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Openstack
|
4
|
+
# :reek:IrresponsibleModule
|
5
|
+
class Client
|
6
|
+
# :reek:IrresponsibleModule
|
7
|
+
class Authenticator
|
8
|
+
# Handles authentication request.
|
9
|
+
class Request
|
10
|
+
include Helpers::HTTPSClient
|
11
|
+
|
12
|
+
attr_reader :credentials, :uri
|
13
|
+
|
14
|
+
delegate :username, :password, to: :credentials
|
15
|
+
|
16
|
+
def initialize(credentials:, uri:)
|
17
|
+
@credentials = credentials
|
18
|
+
@uri = uri
|
19
|
+
end
|
20
|
+
|
21
|
+
def call
|
22
|
+
set_headers
|
23
|
+
set_body
|
24
|
+
https_client.request(request)
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def set_headers
|
30
|
+
request.add_field('Content-Type', 'application/json')
|
31
|
+
end
|
32
|
+
|
33
|
+
def request
|
34
|
+
@request ||= Net::HTTP::Post.new(uri)
|
35
|
+
end
|
36
|
+
|
37
|
+
def set_body
|
38
|
+
request.body = payload
|
39
|
+
end
|
40
|
+
|
41
|
+
def payload
|
42
|
+
<<~JSON.squish
|
43
|
+
{
|
44
|
+
"auth": {
|
45
|
+
"identity": {
|
46
|
+
"methods": [
|
47
|
+
"password"
|
48
|
+
],
|
49
|
+
"password": {
|
50
|
+
"user": {
|
51
|
+
"name": "#{username}",
|
52
|
+
"domain": {
|
53
|
+
"id": "default"
|
54
|
+
},
|
55
|
+
"password": "#{password}"
|
56
|
+
}
|
57
|
+
}
|
58
|
+
}
|
59
|
+
}
|
60
|
+
}
|
61
|
+
JSON
|
62
|
+
end
|
63
|
+
end
|
64
|
+
private_constant :Request
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,126 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Openstack
|
4
|
+
# :reek:IrresponsibleModule
|
5
|
+
class Client
|
6
|
+
# It interacts with Containers/Objects OpenStack API.
|
7
|
+
class Storage
|
8
|
+
include ActiveModel::Model
|
9
|
+
include Helpers::HTTPSClient
|
10
|
+
|
11
|
+
attr_reader :authenticator, :container, :region
|
12
|
+
|
13
|
+
delegate :authenticate_request,
|
14
|
+
to: :authenticator
|
15
|
+
|
16
|
+
validates :authenticator,
|
17
|
+
:container,
|
18
|
+
:region,
|
19
|
+
presence: true
|
20
|
+
|
21
|
+
def initialize(authenticator:, container:, region:)
|
22
|
+
@authenticator = authenticator
|
23
|
+
@container = container
|
24
|
+
@region = region
|
25
|
+
end
|
26
|
+
|
27
|
+
def uri
|
28
|
+
URI(ObjectStoreURL.new(
|
29
|
+
authenticator: authenticator,
|
30
|
+
container: container,
|
31
|
+
region: region
|
32
|
+
).call)
|
33
|
+
end
|
34
|
+
|
35
|
+
def get_object(key, **options)
|
36
|
+
https_client.request(
|
37
|
+
prepare_request do
|
38
|
+
GetObject.new(uri: absolute_uri(key), options: options).request
|
39
|
+
end
|
40
|
+
)
|
41
|
+
end
|
42
|
+
|
43
|
+
def get_object_by_range(key, range, **options)
|
44
|
+
https_client.request(
|
45
|
+
prepare_request do
|
46
|
+
GetObjectByRange.new(
|
47
|
+
uri: absolute_uri(key), range: range, options: options
|
48
|
+
).request
|
49
|
+
end
|
50
|
+
)
|
51
|
+
end
|
52
|
+
|
53
|
+
def put_object(key, io, checksum: nil)
|
54
|
+
https_client.request(
|
55
|
+
prepare_request do
|
56
|
+
PutObject.new(
|
57
|
+
io: io,
|
58
|
+
uri: absolute_uri(key),
|
59
|
+
checksum: checksum
|
60
|
+
).request
|
61
|
+
end
|
62
|
+
)
|
63
|
+
end
|
64
|
+
|
65
|
+
def delete_object(key)
|
66
|
+
https_client.request(
|
67
|
+
prepare_request do
|
68
|
+
DeleteObject.new(uri: absolute_uri(key)).request
|
69
|
+
end
|
70
|
+
)
|
71
|
+
end
|
72
|
+
|
73
|
+
def show_object_metadata(key)
|
74
|
+
https_client.request(
|
75
|
+
prepare_request do
|
76
|
+
ShowObjectMetadata.new(uri: absolute_uri(key)).request
|
77
|
+
end
|
78
|
+
)
|
79
|
+
end
|
80
|
+
|
81
|
+
def list_objects(**options)
|
82
|
+
https_client.request(
|
83
|
+
prepare_request do
|
84
|
+
ListObjects.new(uri: uri, options: options).request
|
85
|
+
end
|
86
|
+
)
|
87
|
+
end
|
88
|
+
|
89
|
+
def create_temporary_uri(key, http_method, **options)
|
90
|
+
CreateTemporaryURI.new(
|
91
|
+
uri: absolute_uri(key),
|
92
|
+
http_method: http_method,
|
93
|
+
options: options
|
94
|
+
).generate
|
95
|
+
end
|
96
|
+
|
97
|
+
def temporary_url(key, http_method, **options)
|
98
|
+
create_temporary_uri(key, http_method, options).to_s
|
99
|
+
end
|
100
|
+
|
101
|
+
def bulk_delete_objects(key_collection)
|
102
|
+
https_client.request(
|
103
|
+
prepare_request do
|
104
|
+
BulkDeleteObjects.new(uri: uri, keys: key_collection).request
|
105
|
+
end
|
106
|
+
)
|
107
|
+
end
|
108
|
+
|
109
|
+
private
|
110
|
+
|
111
|
+
def prepare_request
|
112
|
+
return unless block_given?
|
113
|
+
|
114
|
+
authenticate_request do
|
115
|
+
yield.tap do |request|
|
116
|
+
request.add_field('Accept', 'application/json')
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
def absolute_uri(key)
|
122
|
+
URI("#{uri}/#{key}")
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Openstack
|
4
|
+
# :reek:IrresponsibleModule
|
5
|
+
class Client
|
6
|
+
# :reek:IrresponsibleModule
|
7
|
+
class Storage
|
8
|
+
# Deletes objects in bulk.
|
9
|
+
# More details here: https://docs.openstack.org/swift/latest/middleware.html#bulk-delete
|
10
|
+
class BulkDeleteObjects
|
11
|
+
attr_reader :uri, :keys
|
12
|
+
|
13
|
+
def initialize(uri:, keys: [])
|
14
|
+
@uri = uri
|
15
|
+
@keys = keys
|
16
|
+
end
|
17
|
+
|
18
|
+
def request
|
19
|
+
add_params
|
20
|
+
Net::HTTP::Post.new(uri).tap do |request|
|
21
|
+
request.add_field('Content-type', 'text/plain')
|
22
|
+
request.add_field('Accept', 'application/json')
|
23
|
+
request.body = keys.join("\n")
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def add_params
|
30
|
+
uri.query = URI.encode_www_form('bulk-delete' => nil)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
private_constant :BulkDeleteObjects
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Openstack
|
4
|
+
# :reek:IrresponsibleModule
|
5
|
+
class Client
|
6
|
+
# :reek:IrresponsibleModule
|
7
|
+
class Storage
|
8
|
+
# Create a tempory URL according to OpenStack specification.
|
9
|
+
# More details here: https://docs.openstack.org/swift/latest/api/temporary_url_middleware.html
|
10
|
+
class CreateTemporaryURI
|
11
|
+
attr_reader :uri, :http_method, :options
|
12
|
+
|
13
|
+
def initialize(uri:, http_method:, options: {})
|
14
|
+
@uri = uri
|
15
|
+
@http_method = http_method
|
16
|
+
@options = options
|
17
|
+
end
|
18
|
+
|
19
|
+
def generate
|
20
|
+
add_params
|
21
|
+
uri
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def add_params
|
27
|
+
uri.query = URI.encode_www_form(
|
28
|
+
temp_url_sig: signature,
|
29
|
+
temp_url_expires: expires_in,
|
30
|
+
filename: filename,
|
31
|
+
disposition => nil
|
32
|
+
)
|
33
|
+
end
|
34
|
+
|
35
|
+
def signature
|
36
|
+
OpenSSL::HMAC.hexdigest(algorithm, temporary_url_key, hmac_body)
|
37
|
+
end
|
38
|
+
|
39
|
+
def expires_in
|
40
|
+
@expires_in ||= options.fetch(:expires_in) do
|
41
|
+
15.minutes
|
42
|
+
end.from_now.to_i
|
43
|
+
end
|
44
|
+
|
45
|
+
def filename
|
46
|
+
options.fetch(:filename) { File.basename(uri.path) }
|
47
|
+
end
|
48
|
+
|
49
|
+
def disposition
|
50
|
+
options.fetch(:disposition) { 'inline' }
|
51
|
+
end
|
52
|
+
|
53
|
+
def algorithm
|
54
|
+
'SHA1'
|
55
|
+
end
|
56
|
+
|
57
|
+
def temporary_url_key
|
58
|
+
options.fetch(:temporary_url_key) do
|
59
|
+
Rails.application.credentials.openstack.fetch(:temporary_url_key)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def hmac_body
|
64
|
+
[http_method, expires_in, uri.path].join("\n")
|
65
|
+
end
|
66
|
+
end
|
67
|
+
private_constant :CreateTemporaryURI
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Openstack
|
4
|
+
# :reek:IrresponsibleModule
|
5
|
+
class Client
|
6
|
+
# :reek:IrresponsibleModule
|
7
|
+
class Storage
|
8
|
+
# Deletes object at the specified URI.
|
9
|
+
class DeleteObject
|
10
|
+
attr_reader :uri
|
11
|
+
|
12
|
+
def initialize(uri:)
|
13
|
+
@uri = uri
|
14
|
+
end
|
15
|
+
|
16
|
+
def request
|
17
|
+
Net::HTTP::Delete.new(uri)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
private_constant :DeleteObject
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Openstack
|
4
|
+
# :reek:IrresponsibleModule
|
5
|
+
class Client
|
6
|
+
# :reek:IrresponsibleModule
|
7
|
+
class Storage
|
8
|
+
# Downloads the object at the specified URI.
|
9
|
+
class GetObject
|
10
|
+
attr_reader :uri, :options
|
11
|
+
|
12
|
+
def initialize(uri:, options: {})
|
13
|
+
@uri = uri
|
14
|
+
@options = options
|
15
|
+
end
|
16
|
+
|
17
|
+
def request
|
18
|
+
Net::HTTP::Get.new(uri)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
private_constant :GetObject
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Openstack
|
4
|
+
# :reek:IrresponsibleModule
|
5
|
+
class Client
|
6
|
+
# :reek:IrresponsibleModule
|
7
|
+
class Storage
|
8
|
+
# Downloads the object by chunks at the specified URI.
|
9
|
+
# It uses the `Range` header with byte range.
|
10
|
+
class GetObjectByRange < GetObject
|
11
|
+
attr_reader :range
|
12
|
+
|
13
|
+
def initialize(uri:, range:, options: {})
|
14
|
+
super(uri: uri, options: options)
|
15
|
+
@range = range
|
16
|
+
end
|
17
|
+
|
18
|
+
def request
|
19
|
+
super.tap do |request|
|
20
|
+
request.add_field('Range', byte_range)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def byte_range
|
27
|
+
"bytes=#{first_byte}-#{last_byte}"
|
28
|
+
end
|
29
|
+
|
30
|
+
def first_byte
|
31
|
+
range.begin
|
32
|
+
end
|
33
|
+
|
34
|
+
def last_byte
|
35
|
+
range.exclude_end? ? range_end - 1 : range_end
|
36
|
+
end
|
37
|
+
|
38
|
+
def range_end
|
39
|
+
range.end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
private_constant :GetObjectByRange
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Openstack
|
4
|
+
# :reek:IrresponsibleModule
|
5
|
+
class Client
|
6
|
+
# :reek:IrresponsibleModule
|
7
|
+
class Storage
|
8
|
+
# List objects at the specified URI.
|
9
|
+
# Generally a container is specified.
|
10
|
+
# The `prefix=` url variable filters the list retrieved.
|
11
|
+
class ListObjects
|
12
|
+
attr_reader :uri, :options
|
13
|
+
|
14
|
+
def initialize(uri:, options: {})
|
15
|
+
@uri = uri
|
16
|
+
@options = options
|
17
|
+
end
|
18
|
+
|
19
|
+
def request
|
20
|
+
add_params
|
21
|
+
Net::HTTP::Get.new(uri)
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def add_params
|
27
|
+
uri.query = URI.encode_www_form(options)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
private_constant :ListObjects
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Openstack
|
4
|
+
# :reek:IrresponsibleModule
|
5
|
+
class Client
|
6
|
+
# :reek:IrresponsibleModule
|
7
|
+
class Storage
|
8
|
+
# Extracts the object store URL from cached payload mathing the
|
9
|
+
# specified region.
|
10
|
+
class ObjectStoreURL
|
11
|
+
include Helpers::CacheReaderable
|
12
|
+
|
13
|
+
attr_reader :authenticator, :container, :region
|
14
|
+
|
15
|
+
delegate :cache, :cache_key, :authenticate, to: :authenticator
|
16
|
+
|
17
|
+
def initialize(authenticator:, container:, region:)
|
18
|
+
@authenticator = authenticator
|
19
|
+
@container = container
|
20
|
+
@region = region
|
21
|
+
authenticate
|
22
|
+
end
|
23
|
+
|
24
|
+
def call
|
25
|
+
"#{regionized_object_store_url}/#{container}"
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def regionized_object_store_url
|
31
|
+
object_store_endpoints.find do |endpoint|
|
32
|
+
endpoint.fetch('region') == region
|
33
|
+
end.fetch('url')
|
34
|
+
end
|
35
|
+
|
36
|
+
def object_store_endpoints
|
37
|
+
catalog_collection.find do |catalog|
|
38
|
+
catalog.fetch('type') == 'object-store'
|
39
|
+
end.fetch('endpoints', [])
|
40
|
+
end
|
41
|
+
|
42
|
+
def catalog_collection
|
43
|
+
read_from_cache.dig('body', 'token', 'catalog') || []
|
44
|
+
end
|
45
|
+
end
|
46
|
+
private_constant :ObjectStoreURL
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'digest'
|
4
|
+
require 'mimemagic'
|
5
|
+
|
6
|
+
module Openstack
|
7
|
+
# :reek:IrresponsibleModule
|
8
|
+
class Client
|
9
|
+
# :reek:IrresponsibleModule
|
10
|
+
class Storage
|
11
|
+
# Uploads a file to the Object Store.
|
12
|
+
# Checksum is validated after upload.
|
13
|
+
class PutObject
|
14
|
+
attr_reader :checksum, :io, :uri
|
15
|
+
|
16
|
+
def initialize(io:, uri:, checksum: nil)
|
17
|
+
@io = io
|
18
|
+
@uri = uri
|
19
|
+
@checksum = checksum
|
20
|
+
end
|
21
|
+
|
22
|
+
def request
|
23
|
+
Net::HTTP::Put.new(uri).tap do |request|
|
24
|
+
request.add_field('Content-Type', content_type)
|
25
|
+
request.add_field('ETag', md5_checksum)
|
26
|
+
request.body = io.read
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def content_type
|
33
|
+
Marcel::MimeType.for io,
|
34
|
+
name: io.try(:original_filename),
|
35
|
+
declared_type: io.try(:content_type)
|
36
|
+
end
|
37
|
+
|
38
|
+
def md5_checksum
|
39
|
+
return checksum_to_hexdigest if checksum.present?
|
40
|
+
|
41
|
+
Digest::MD5.file(io).hexdigest
|
42
|
+
end
|
43
|
+
|
44
|
+
# ActiveStorage sends a `Digest::MD5.base64digest` checksum
|
45
|
+
# OpenStack expects a `Digest::MD5.hexdigest` ETag
|
46
|
+
def checksum_to_hexdigest
|
47
|
+
checksum.unpack1('m0').unpack1('H*')
|
48
|
+
end
|
49
|
+
end
|
50
|
+
private_constant :PutObject
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Openstack
|
4
|
+
# :reek:IrresponsibleModule
|
5
|
+
class Client
|
6
|
+
# :reek:IrresponsibleModule
|
7
|
+
class Storage
|
8
|
+
# Shows the object metadata without downloading it.
|
9
|
+
# Useful to check if object exists.
|
10
|
+
class ShowObjectMetadata
|
11
|
+
attr_reader :uri
|
12
|
+
|
13
|
+
def initialize(uri:)
|
14
|
+
@uri = uri
|
15
|
+
end
|
16
|
+
|
17
|
+
def request
|
18
|
+
Net::HTTP::Head.new(uri)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
private_constant :ShowObjectMetadata
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Openstack
|
4
|
+
module Helpers
|
5
|
+
# Methods to interact with cache.
|
6
|
+
module CacheReaderable
|
7
|
+
def cache_key
|
8
|
+
"openstack/token-#{username}"
|
9
|
+
end
|
10
|
+
|
11
|
+
def read_from_cache
|
12
|
+
@read_from_cache ||= JSON.parse(cache.read(cache_key))
|
13
|
+
rescue TypeError
|
14
|
+
null_cache_placeholder
|
15
|
+
end
|
16
|
+
|
17
|
+
def null_cache_placeholder
|
18
|
+
{
|
19
|
+
'headers' => nil,
|
20
|
+
'token' => nil,
|
21
|
+
'expires_at' => nil,
|
22
|
+
'code' => nil,
|
23
|
+
'message' => nil,
|
24
|
+
'body' => nil
|
25
|
+
}
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Openstack
|
4
|
+
module Helpers
|
5
|
+
# cache-friendly response body.
|
6
|
+
module CacheableBody
|
7
|
+
def body_to_cache
|
8
|
+
# We cache JSON rather than ruby object. Simple object.
|
9
|
+
{
|
10
|
+
headers: headers,
|
11
|
+
token: token,
|
12
|
+
expires_at: expires_at,
|
13
|
+
code: Integer(code),
|
14
|
+
message: message,
|
15
|
+
body: body_as_hash
|
16
|
+
}.to_json
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
def headers
|
22
|
+
each_header.to_h
|
23
|
+
end
|
24
|
+
|
25
|
+
def token
|
26
|
+
header.fetch('X-Subject-Token') { nil }
|
27
|
+
end
|
28
|
+
|
29
|
+
def expires_at
|
30
|
+
Time.parse(body_as_hash.dig('token', 'expires_at'))
|
31
|
+
rescue TypeError
|
32
|
+
nil
|
33
|
+
end
|
34
|
+
|
35
|
+
def body_as_hash
|
36
|
+
JSON.parse(body)
|
37
|
+
rescue JSON::ParserError
|
38
|
+
{}
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Openstack
|
4
|
+
module Helpers
|
5
|
+
# Enables SSL mode for the specified uri.
|
6
|
+
module HTTPSClient
|
7
|
+
def https_client
|
8
|
+
Net::HTTP.new(uri.host, uri.port).tap do |client|
|
9
|
+
client.use_ssl = true
|
10
|
+
client.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'zeitwerk'
|
4
|
+
|
5
|
+
# Inflector for Zeitwerk
|
6
|
+
class CustomInflector < Zeitwerk::Inflector
|
7
|
+
# :reek:ControlParameter imposed by Zeitwerk gem
|
8
|
+
def camelize(basename, _abspath)
|
9
|
+
case basename
|
10
|
+
when 'https_client'
|
11
|
+
'HTTPSClient'
|
12
|
+
when 'object_store_url'
|
13
|
+
'ObjectStoreURL'
|
14
|
+
when 'create_temporary_uri'
|
15
|
+
'CreateTemporaryURI'
|
16
|
+
else
|
17
|
+
super
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
loader = Zeitwerk::Loader.new
|
23
|
+
# loader.logger = method(:puts)
|
24
|
+
loader.inflector = CustomInflector.new
|
25
|
+
loader.preload("#{GEM_ROOT}/active_storage/service/openstack_service.rb")
|
26
|
+
loader.push_dir(GEM_ROOT)
|
27
|
+
loader.ignore(__dir__)
|
28
|
+
loader.ignore("#{GEM_ROOT}/activestorage_openstack.rb")
|
29
|
+
loader.setup
|
metadata
ADDED
@@ -0,0 +1,152 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: activestorage_openstack
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.2
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Mickael Palma
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2019-03-14 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: marcel
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0.3'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0.3'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: rails
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: 6.0.0.beta2
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: 6.0.0.beta2
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: tzinfo-data
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '1.2'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '1.2'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: zeitwerk
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '1.3'
|
62
|
+
type: :runtime
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '1.3'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: rspec-rails
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - "~>"
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '3.8'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - "~>"
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '3.8'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: sqlite3
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - "~>"
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: 1.3.6
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - "~>"
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: 1.3.6
|
97
|
+
description: OpenStack ActiveStorage service without dependencies.
|
98
|
+
email:
|
99
|
+
- mpalma@largus.fr
|
100
|
+
executables: []
|
101
|
+
extensions: []
|
102
|
+
extra_rdoc_files: []
|
103
|
+
files:
|
104
|
+
- MIT-LICENSE
|
105
|
+
- README.md
|
106
|
+
- Rakefile
|
107
|
+
- config/spring.rb
|
108
|
+
- lib/active_storage/openstack/railtie.rb
|
109
|
+
- lib/active_storage/openstack/version.rb
|
110
|
+
- lib/active_storage/service/openstack_service.rb
|
111
|
+
- lib/activestorage_openstack.rb
|
112
|
+
- lib/openstack/client.rb
|
113
|
+
- lib/openstack/client/authenticator.rb
|
114
|
+
- lib/openstack/client/authenticator/request.rb
|
115
|
+
- lib/openstack/client/storage.rb
|
116
|
+
- lib/openstack/client/storage/bulk_delete_objects.rb
|
117
|
+
- lib/openstack/client/storage/create_temporary_uri.rb
|
118
|
+
- lib/openstack/client/storage/delete_object.rb
|
119
|
+
- lib/openstack/client/storage/get_object.rb
|
120
|
+
- lib/openstack/client/storage/get_object_by_range.rb
|
121
|
+
- lib/openstack/client/storage/list_objects.rb
|
122
|
+
- lib/openstack/client/storage/object_store_url.rb
|
123
|
+
- lib/openstack/client/storage/put_object.rb
|
124
|
+
- lib/openstack/client/storage/show_object_metadata.rb
|
125
|
+
- lib/openstack/helpers/cache_readerable.rb
|
126
|
+
- lib/openstack/helpers/cacheable_body.rb
|
127
|
+
- lib/openstack/helpers/https_client.rb
|
128
|
+
- lib/support/zeitwerk.rb
|
129
|
+
homepage: https://github.com/mickael-palma-argus/activestorage-openstack
|
130
|
+
licenses:
|
131
|
+
- MIT
|
132
|
+
metadata: {}
|
133
|
+
post_install_message:
|
134
|
+
rdoc_options: []
|
135
|
+
require_paths:
|
136
|
+
- lib
|
137
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
138
|
+
requirements:
|
139
|
+
- - ">="
|
140
|
+
- !ruby/object:Gem::Version
|
141
|
+
version: '0'
|
142
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
143
|
+
requirements:
|
144
|
+
- - ">="
|
145
|
+
- !ruby/object:Gem::Version
|
146
|
+
version: '0'
|
147
|
+
requirements: []
|
148
|
+
rubygems_version: 3.0.3
|
149
|
+
signing_key:
|
150
|
+
specification_version: 4
|
151
|
+
summary: OpenStack ActiveStorage service.
|
152
|
+
test_files: []
|