kakin 0.0.3
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +28 -0
- data/Gemfile +3 -0
- data/LICENSE +22 -0
- data/README.md +25 -0
- data/Rakefile +10 -0
- data/bin/console +14 -0
- data/bin/setup +7 -0
- data/cost.yaml.sample +5 -0
- data/exe/kakin +7 -0
- data/kakin.gemspec +27 -0
- data/lib/kakin.rb +6 -0
- data/lib/kakin/cli.rb +141 -0
- data/lib/kakin/configuration.rb +27 -0
- data/lib/kakin/version.rb +3 -0
- data/lib/kakin/yao_ext/server.rb +10 -0
- data/lib/kakin/yao_ext/tenant.rb +35 -0
- metadata +132 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 3bdc1d071c18078ed62113fda208987cb1a00acb
|
4
|
+
data.tar.gz: 5fef69e865080c33b8ad4c6acf27719e0f8e5057
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: fe4b4b84a4b7c3b3659af0a700cf2b1eaeb22fcdab2eb6a3b00cc3f4bba91e0c3c46517b66b0992eab56394608aac9806540756754d96e2d06bc998bee6923f4
|
7
|
+
data.tar.gz: 81c4ed52991a268bee1079e648c17c1ad49969189f42688d8a124a448873685bdbed1fcdb2c659c59a6151a9a83f4462d967c60edeef889b6e0436c24f708a62
|
data/.gitignore
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
*.gem
|
2
|
+
*.rbc
|
3
|
+
/.config
|
4
|
+
/coverage/
|
5
|
+
/InstalledFiles
|
6
|
+
/pkg/
|
7
|
+
/spec/reports/
|
8
|
+
/test/tmp/
|
9
|
+
/test/version_tmp/
|
10
|
+
/tmp/
|
11
|
+
|
12
|
+
## Documentation cache and generated files:
|
13
|
+
/.yardoc/
|
14
|
+
/_yardoc/
|
15
|
+
/doc/
|
16
|
+
/rdoc/
|
17
|
+
|
18
|
+
## Environment normalisation:
|
19
|
+
/.bundle/
|
20
|
+
/vendor/bundle
|
21
|
+
/lib/bundler/man/
|
22
|
+
|
23
|
+
.ruby-version
|
24
|
+
.ruby-gemset
|
25
|
+
.rvmrc
|
26
|
+
|
27
|
+
/cost.yaml
|
28
|
+
/Gemfile.lock
|
data/Gemfile
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2015 buty4649
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21
|
+
SOFTWARE.
|
22
|
+
|
data/README.md
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
# kakin
|
2
|
+
OpenStackのリソース使用料金按分ツール
|
3
|
+
|
4
|
+
## Usage
|
5
|
+
|
6
|
+
```
|
7
|
+
$ mv cost.yaml.sample cost.yaml
|
8
|
+
$ vi cost.yaml
|
9
|
+
```
|
10
|
+
|
11
|
+
You need to create configuration file located `~/.kakin` for openstack credential like this.
|
12
|
+
|
13
|
+
```
|
14
|
+
auth_url: "http://your-openstack-host:35357/v2.0/tokens"
|
15
|
+
management_url: "http://your-openstack-host:8774/v2"
|
16
|
+
username: "username"
|
17
|
+
tenant: "your-admin-tenant"
|
18
|
+
password: "password"
|
19
|
+
```
|
20
|
+
|
21
|
+
You can get resource usage with following command.
|
22
|
+
|
23
|
+
```
|
24
|
+
$ kakin -f cost.yaml
|
25
|
+
```
|
data/Rakefile
ADDED
data/bin/console
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "kakin"
|
5
|
+
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
8
|
+
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
+
# require "pry"
|
11
|
+
# Pry.start
|
12
|
+
|
13
|
+
require "irb"
|
14
|
+
IRB.start
|
data/bin/setup
ADDED
data/cost.yaml.sample
ADDED
data/exe/kakin
ADDED
data/kakin.gemspec
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'kakin/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "kakin"
|
8
|
+
spec.version = Kakin::VERSION
|
9
|
+
spec.authors = ["buty4649", "SHIBATA Hiroshi"]
|
10
|
+
spec.email = ["", "hsbt@ruby-lang.org"]
|
11
|
+
|
12
|
+
spec.summary = %q{kakin is resource calcuration tool for OpenStack}
|
13
|
+
spec.description = %q{kakin is resource calcuration tool for OpenStack}
|
14
|
+
spec.homepage = "https://github.com/yaocloud/kakin"
|
15
|
+
|
16
|
+
spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
17
|
+
spec.bindir = "exe"
|
18
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
19
|
+
spec.require_paths = ["lib"]
|
20
|
+
|
21
|
+
spec.add_dependency 'thor'
|
22
|
+
spec.add_dependency 'yao', '~> 0.2.13'
|
23
|
+
|
24
|
+
spec.add_development_dependency "bundler", "~> 1.10"
|
25
|
+
spec.add_development_dependency "rake", "~> 10.0"
|
26
|
+
spec.add_development_dependency "minitest"
|
27
|
+
end
|
data/lib/kakin.rb
ADDED
data/lib/kakin/cli.rb
ADDED
@@ -0,0 +1,141 @@
|
|
1
|
+
require 'date'
|
2
|
+
require 'yaml'
|
3
|
+
require 'json'
|
4
|
+
require 'net/http'
|
5
|
+
require 'yao'
|
6
|
+
require 'kakin/yao_ext/tenant'
|
7
|
+
require 'kakin/yao_ext/server'
|
8
|
+
require 'thor'
|
9
|
+
|
10
|
+
module Kakin
|
11
|
+
class CLI <Thor
|
12
|
+
default_command :calc
|
13
|
+
|
14
|
+
option :f, type: :string, banner: "<file>", desc: "cost define file(yaml)", required: true
|
15
|
+
option :s, type: :string, banner: "<start>", desc: "start time", default: (DateTime.now << 1).strftime("%Y-%m-01")
|
16
|
+
option :e, type: :string, banner: "<end>", desc: "end time", default: Time.now.strftime("%Y-%m-01")
|
17
|
+
option :t, type: :string, banner: "<tenant>", desc: "specify tenant", default: ""
|
18
|
+
desc 'calc', 'Calculate the cost'
|
19
|
+
def calc
|
20
|
+
Kakin::Configuration.setup
|
21
|
+
|
22
|
+
yaml = YAML.load_file(options[:f])
|
23
|
+
start_time = Time.parse(options[:s]).strftime("%FT%T")
|
24
|
+
end_time = Time.parse(options[:e]).strftime("%FT%T")
|
25
|
+
|
26
|
+
STDERR.puts "Start: #{start_time}"
|
27
|
+
STDERR.puts "End: #{end_time}"
|
28
|
+
url = URI.parse("#{Kakin::Configuration.management_url}/#{Yao::Tenant.get_by_name(Kakin::Configuration.tenant).id}/os-simple-tenant-usage?start=#{start_time}&end=#{end_time}")
|
29
|
+
req = Net::HTTP::Get.new(url)
|
30
|
+
req["Accept"] = "application/json"
|
31
|
+
req["X-Auth-Token"] = Yao::Auth.try_new.token
|
32
|
+
res = Net::HTTP.start(url.host, url.port) {|http|
|
33
|
+
http.request(req)
|
34
|
+
}
|
35
|
+
|
36
|
+
if res.code != "200"
|
37
|
+
raise "usage data fatch is failed"
|
38
|
+
else
|
39
|
+
result = Hash.new
|
40
|
+
tenant_usages = JSON.load(res.body)["tenant_usages"]
|
41
|
+
tenants = Yao::Tenant.list
|
42
|
+
|
43
|
+
unless options[:t].empty?
|
44
|
+
tenant = tenants.find { |tenant| tenant.name == options[:t] }
|
45
|
+
raise "Not Found tenant #{options[:t]}" unless tenant
|
46
|
+
|
47
|
+
tenant_usages = tenant_usages.select { |tenant_usage| tenant_usage["tenant_id"] == tenant.id }
|
48
|
+
end
|
49
|
+
|
50
|
+
tenant_usages.each do |usage|
|
51
|
+
tenant = tenants.find { |tenant| tenant.id == usage["tenant_id"] }
|
52
|
+
|
53
|
+
total_vcpus_usage = usage["total_vcpus_usage"]
|
54
|
+
total_memory_mb_usage = usage["total_memory_mb_usage"]
|
55
|
+
total_local_gb_usage = usage["total_local_gb_usage"]
|
56
|
+
|
57
|
+
bill_vcpu = total_vcpus_usage * yaml["vcpu_per_hour"]
|
58
|
+
bill_memory = total_memory_mb_usage * yaml["memory_mb_per_hour"]
|
59
|
+
bill_disk = total_local_gb_usage * yaml["disk_gb_per_hour"]
|
60
|
+
|
61
|
+
result[tenant.name] = {
|
62
|
+
'bill_total' => bill_vcpu + bill_memory + bill_disk,
|
63
|
+
'bill_vcpu' => bill_vcpu,
|
64
|
+
'bill_memory' => bill_memory,
|
65
|
+
'bill_disk' => bill_disk,
|
66
|
+
'total_hours' => usage["total_hours"],
|
67
|
+
'total_vcpus_usage' => total_vcpus_usage,
|
68
|
+
'total_memory_mb_usage' => total_memory_mb_usage,
|
69
|
+
'total_local_gb_usage' => total_local_gb_usage,
|
70
|
+
}
|
71
|
+
end
|
72
|
+
|
73
|
+
puts YAML.dump(result)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
option :f, type: :string, banner: "<file>", desc: "cost define file(yaml)", required: true
|
78
|
+
option :s, type: :string, banner: "<start>", desc: "start time", default: (DateTime.now << 1).strftime("%Y-%m-01")
|
79
|
+
option :e, type: :string, banner: "<end>", desc: "end time", default: Time.now.strftime("%Y-%m-01")
|
80
|
+
option :t, type: :string, banner: "<tenant>", desc: "specify tenant", default: ""
|
81
|
+
desc 'network', 'network resource'
|
82
|
+
def network
|
83
|
+
Kakin::Configuration.setup
|
84
|
+
|
85
|
+
yaml = YAML.load_file(options[:f])
|
86
|
+
start_time = Time.parse(options[:s])
|
87
|
+
end_time = Time.parse(options[:e])
|
88
|
+
|
89
|
+
STDERR.puts "Start: #{start_time}"
|
90
|
+
STDERR.puts "End: #{end_time}"
|
91
|
+
|
92
|
+
result = Hash.new
|
93
|
+
tenants = unless options[:t].empty?
|
94
|
+
Yao::Tenant.list(name: options[:t])
|
95
|
+
else
|
96
|
+
Yao::Tenant.list
|
97
|
+
end
|
98
|
+
tenants = [tenants] unless tenants.is_a?(Array)
|
99
|
+
|
100
|
+
tenants.each do |tenant|
|
101
|
+
incoming = tenant.network_usage(Regexp.new(yaml["ip_regexp"]), :incoming, start_time.iso8601, end_time.iso8601)
|
102
|
+
outgoing = tenant.network_usage(Regexp.new(yaml["ip_regexp"]), :outgoing, start_time.iso8601, end_time.iso8601)
|
103
|
+
result[tenant.name] = {
|
104
|
+
'incoming_usage' => incoming,
|
105
|
+
'outgoing_usage' => outgoing,
|
106
|
+
'total_usage' => incoming + outgoing
|
107
|
+
}
|
108
|
+
end
|
109
|
+
|
110
|
+
puts YAML.dump(result)
|
111
|
+
end
|
112
|
+
|
113
|
+
option :f, type: :string, banner: "<file>", desc: "cost define file(yaml)", required: true
|
114
|
+
option :t, type: :string, banner: "<tenant>", desc: "specify tenant", default: ""
|
115
|
+
desc 'ip', 'ip use count'
|
116
|
+
def ip
|
117
|
+
Kakin::Configuration.setup
|
118
|
+
|
119
|
+
yaml = YAML.load_file(options[:f])
|
120
|
+
ip_regexp = Regexp.new(yaml["ip_regexp"])
|
121
|
+
|
122
|
+
result = Hash.new
|
123
|
+
tenants = unless options[:t].empty?
|
124
|
+
Yao::Tenant.list(name: options[:t])
|
125
|
+
else
|
126
|
+
Yao::Tenant.list
|
127
|
+
end
|
128
|
+
tenants = [tenants] unless tenants.is_a?(Array)
|
129
|
+
|
130
|
+
tenants.each do |tenant|
|
131
|
+
count = tenant.ports.select {|p| p.fixed_ips[0]["ip_address"] =~ ip_regexp}.count
|
132
|
+
result[tenant.name] = {
|
133
|
+
'count' => count,
|
134
|
+
'total_usage' => count * yaml["cost_per_ip"],
|
135
|
+
}
|
136
|
+
end
|
137
|
+
|
138
|
+
puts YAML.dump(result)
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
module Kakin
|
2
|
+
class Configuration
|
3
|
+
|
4
|
+
def self.management_url
|
5
|
+
@@_management_url
|
6
|
+
end
|
7
|
+
|
8
|
+
def self.tenant
|
9
|
+
@@_tenant
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.setup
|
13
|
+
yaml = YAML.load_file(File.expand_path('~/.kakin'))
|
14
|
+
|
15
|
+
@@_management_url = yaml['management_url']
|
16
|
+
@@_tenant = yaml['tenant']
|
17
|
+
|
18
|
+
Yao.configure do
|
19
|
+
auth_url yaml['auth_url']
|
20
|
+
tenant_name yaml['tenant']
|
21
|
+
username yaml['username']
|
22
|
+
password yaml['password']
|
23
|
+
timeout yaml['timeout'] if yaml['timeout']
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
require 'yao/resources/tenant'
|
2
|
+
|
3
|
+
module Yao::Resources
|
4
|
+
class Tenant < Base
|
5
|
+
def network_usage(ip_regexp, type, start_time, end_time)
|
6
|
+
servers.inject(0) do |t, server|
|
7
|
+
samples = server.old_samples(counter_name: "network.#{type}.bytes", query: {'q.field': 'timestamp', 'q.op': 'gt', 'q.value': start_time})
|
8
|
+
if samples.empty?
|
9
|
+
t
|
10
|
+
else
|
11
|
+
wan_samples = samples.select{|s| s.resource_metadata["mac"] == server.mac_address(ip_regexp) }.sort_by(&:timestamp)
|
12
|
+
if wan_samples.empty? || (wan_samples.size == 1)
|
13
|
+
t
|
14
|
+
else
|
15
|
+
last_sample_index = wan_samples.find_index{|s| s.timestamp > Time.parse(end_time) }
|
16
|
+
last_sample = if last_sample_index
|
17
|
+
wan_samples[last_sample_index]
|
18
|
+
else
|
19
|
+
wan_samples[-1]
|
20
|
+
end
|
21
|
+
transferred_bits = last_sample.counter_volume - wan_samples[0].counter_volume
|
22
|
+
period = (last_sample.timestamp - wan_samples[0].timestamp).to_i
|
23
|
+
# ignored negative number or zero period. Thease are unexpected data.
|
24
|
+
# We need to investigate why generate these counter.
|
25
|
+
if transferred_bits < 0 || period.zero?
|
26
|
+
t
|
27
|
+
else
|
28
|
+
t + transferred_bits / period
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
metadata
ADDED
@@ -0,0 +1,132 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: kakin
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.3
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- buty4649
|
8
|
+
- SHIBATA Hiroshi
|
9
|
+
autorequire:
|
10
|
+
bindir: exe
|
11
|
+
cert_chain: []
|
12
|
+
date: 2017-01-20 00:00:00.000000000 Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: thor
|
16
|
+
requirement: !ruby/object:Gem::Requirement
|
17
|
+
requirements:
|
18
|
+
- - ">="
|
19
|
+
- !ruby/object:Gem::Version
|
20
|
+
version: '0'
|
21
|
+
type: :runtime
|
22
|
+
prerelease: false
|
23
|
+
version_requirements: !ruby/object:Gem::Requirement
|
24
|
+
requirements:
|
25
|
+
- - ">="
|
26
|
+
- !ruby/object:Gem::Version
|
27
|
+
version: '0'
|
28
|
+
- !ruby/object:Gem::Dependency
|
29
|
+
name: yao
|
30
|
+
requirement: !ruby/object:Gem::Requirement
|
31
|
+
requirements:
|
32
|
+
- - "~>"
|
33
|
+
- !ruby/object:Gem::Version
|
34
|
+
version: 0.2.13
|
35
|
+
type: :runtime
|
36
|
+
prerelease: false
|
37
|
+
version_requirements: !ruby/object:Gem::Requirement
|
38
|
+
requirements:
|
39
|
+
- - "~>"
|
40
|
+
- !ruby/object:Gem::Version
|
41
|
+
version: 0.2.13
|
42
|
+
- !ruby/object:Gem::Dependency
|
43
|
+
name: bundler
|
44
|
+
requirement: !ruby/object:Gem::Requirement
|
45
|
+
requirements:
|
46
|
+
- - "~>"
|
47
|
+
- !ruby/object:Gem::Version
|
48
|
+
version: '1.10'
|
49
|
+
type: :development
|
50
|
+
prerelease: false
|
51
|
+
version_requirements: !ruby/object:Gem::Requirement
|
52
|
+
requirements:
|
53
|
+
- - "~>"
|
54
|
+
- !ruby/object:Gem::Version
|
55
|
+
version: '1.10'
|
56
|
+
- !ruby/object:Gem::Dependency
|
57
|
+
name: rake
|
58
|
+
requirement: !ruby/object:Gem::Requirement
|
59
|
+
requirements:
|
60
|
+
- - "~>"
|
61
|
+
- !ruby/object:Gem::Version
|
62
|
+
version: '10.0'
|
63
|
+
type: :development
|
64
|
+
prerelease: false
|
65
|
+
version_requirements: !ruby/object:Gem::Requirement
|
66
|
+
requirements:
|
67
|
+
- - "~>"
|
68
|
+
- !ruby/object:Gem::Version
|
69
|
+
version: '10.0'
|
70
|
+
- !ruby/object:Gem::Dependency
|
71
|
+
name: minitest
|
72
|
+
requirement: !ruby/object:Gem::Requirement
|
73
|
+
requirements:
|
74
|
+
- - ">="
|
75
|
+
- !ruby/object:Gem::Version
|
76
|
+
version: '0'
|
77
|
+
type: :development
|
78
|
+
prerelease: false
|
79
|
+
version_requirements: !ruby/object:Gem::Requirement
|
80
|
+
requirements:
|
81
|
+
- - ">="
|
82
|
+
- !ruby/object:Gem::Version
|
83
|
+
version: '0'
|
84
|
+
description: kakin is resource calcuration tool for OpenStack
|
85
|
+
email:
|
86
|
+
- ''
|
87
|
+
- hsbt@ruby-lang.org
|
88
|
+
executables:
|
89
|
+
- kakin
|
90
|
+
extensions: []
|
91
|
+
extra_rdoc_files: []
|
92
|
+
files:
|
93
|
+
- ".gitignore"
|
94
|
+
- Gemfile
|
95
|
+
- LICENSE
|
96
|
+
- README.md
|
97
|
+
- Rakefile
|
98
|
+
- bin/console
|
99
|
+
- bin/setup
|
100
|
+
- cost.yaml.sample
|
101
|
+
- exe/kakin
|
102
|
+
- kakin.gemspec
|
103
|
+
- lib/kakin.rb
|
104
|
+
- lib/kakin/cli.rb
|
105
|
+
- lib/kakin/configuration.rb
|
106
|
+
- lib/kakin/version.rb
|
107
|
+
- lib/kakin/yao_ext/server.rb
|
108
|
+
- lib/kakin/yao_ext/tenant.rb
|
109
|
+
homepage: https://github.com/yaocloud/kakin
|
110
|
+
licenses: []
|
111
|
+
metadata: {}
|
112
|
+
post_install_message:
|
113
|
+
rdoc_options: []
|
114
|
+
require_paths:
|
115
|
+
- lib
|
116
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
117
|
+
requirements:
|
118
|
+
- - ">="
|
119
|
+
- !ruby/object:Gem::Version
|
120
|
+
version: '0'
|
121
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
122
|
+
requirements:
|
123
|
+
- - ">="
|
124
|
+
- !ruby/object:Gem::Version
|
125
|
+
version: '0'
|
126
|
+
requirements: []
|
127
|
+
rubyforge_project:
|
128
|
+
rubygems_version: 2.6.8
|
129
|
+
signing_key:
|
130
|
+
specification_version: 4
|
131
|
+
summary: kakin is resource calcuration tool for OpenStack
|
132
|
+
test_files: []
|