activestorage_openstack 0.1.2
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 +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
|
+
[](https://codeclimate.com/github/argus-api-team/activestorage-openstack/maintainability) [](https://codeclimate.com/github/argus-api-team/activestorage-openstack/test_coverage) [](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: []
|