knife-rackspace 0.6.2 → 0.7.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.
- data/.chef/knife.rb +18 -0
- data/.gitignore +2 -0
- data/.travis.yml +28 -0
- data/CHANGELOG.md +12 -0
- data/Gemfile +4 -0
- data/README.rdoc +17 -3
- data/Rakefile +30 -0
- data/knife-rackspace.gemspec +7 -0
- data/lib/chef/knife/rackspace_base.rb +52 -29
- data/lib/chef/knife/rackspace_network_create.rb +46 -0
- data/lib/chef/knife/rackspace_network_delete.rb +37 -0
- data/lib/chef/knife/rackspace_network_list.rb +31 -0
- data/lib/chef/knife/rackspace_server_create.rb +193 -15
- data/lib/knife-rackspace/version.rb +1 -1
- data/spec/cassettes/v1/should_list_images.yml +123 -0
- data/spec/cassettes/v1/should_list_server_flavors.yml +462 -0
- data/spec/cassettes/v2/should_list_images.yml +293 -0
- data/spec/cassettes/v2/should_list_server_flavors.yml +361 -0
- data/spec/integration/integration_spec.rb +90 -0
- data/spec/integration_spec_helper.rb +134 -0
- data/spec/spec_helper.rb +3 -0
- metadata +144 -62
@@ -0,0 +1,90 @@
|
|
1
|
+
require 'integration_spec_helper'
|
2
|
+
require 'fog'
|
3
|
+
require 'knife/dsl'
|
4
|
+
require 'chef/knife/rackspace_server_create'
|
5
|
+
# include Chef::Knife::DSL
|
6
|
+
|
7
|
+
[:v1, :v2].each do |api|
|
8
|
+
describe api do
|
9
|
+
before(:each) do
|
10
|
+
Chef::Config[:knife][:rackspace_version] = api.to_s #v2 by default
|
11
|
+
|
12
|
+
Chef::Knife::Bootstrap.any_instance.stub(:run)
|
13
|
+
Chef::Knife::RackspaceServerCreate.any_instance.stub(:tcp_test_ssh).with(anything).and_return(true)
|
14
|
+
end
|
15
|
+
|
16
|
+
it 'should list server flavors', :vcr do
|
17
|
+
stdout, stderr, status = knife_capture('rackspace flavor list')
|
18
|
+
status.should be(0), "Non-zero exit code.\n#{stdout}\n#{stderr}"
|
19
|
+
|
20
|
+
expected_output = {
|
21
|
+
:v1 => """
|
22
|
+
ID Name Architecture RAM Disk
|
23
|
+
1 256 server 64-bit 256 10 GB
|
24
|
+
2 512 server 64-bit 512 20 GB
|
25
|
+
3 1GB server 64-bit 1024 40 GB
|
26
|
+
4 2GB server 64-bit 2048 80 GB
|
27
|
+
5 4GB server 64-bit 4096 160 GB
|
28
|
+
6 8GB server 64-bit 8192 320 GB
|
29
|
+
7 15.5GB server 64-bit 15872 620 GB
|
30
|
+
8 30GB server 64-bit 30720 1200 GB
|
31
|
+
""",
|
32
|
+
:v2 => """
|
33
|
+
ID Name VCPUs RAM Disk
|
34
|
+
2 512MB Standard Instance 1 512 20 GB
|
35
|
+
3 1GB Standard Instance 1 1024 40 GB
|
36
|
+
4 2GB Standard Instance 2 2048 80 GB
|
37
|
+
5 4GB Standard Instance 2 4096 160 GB
|
38
|
+
6 8GB Standard Instance 4 8192 320 GB
|
39
|
+
7 15GB Standard Instance 6 15360 620 GB
|
40
|
+
8 30GB Standard Instance 8 30720 1200 GB
|
41
|
+
"""}
|
42
|
+
stdout = ANSI.unansi stdout
|
43
|
+
stdout.should match_output(expected_output[api])
|
44
|
+
end
|
45
|
+
|
46
|
+
it 'should list images', :vcr do
|
47
|
+
sample_image = {
|
48
|
+
:v1 => 'Ubuntu 12.04 LTS',
|
49
|
+
:v2 => 'Ubuntu 12.04 LTS (Precise Pangolin)'
|
50
|
+
}
|
51
|
+
|
52
|
+
stdout, stderr, status = knife_capture('rackspace image list')
|
53
|
+
status.should be(0), "Non-zero exit code.\n#{stdout}\n#{stderr}"
|
54
|
+
stdout = clean_output(stdout)
|
55
|
+
stdout.should match /^ID\s*Name\s*$/
|
56
|
+
stdout.should include sample_image[api]
|
57
|
+
end
|
58
|
+
|
59
|
+
it 'should manage servers', :vcr do
|
60
|
+
pending "The test works, but I'm in the process of cleaning up sensitive data in the cassettes"
|
61
|
+
|
62
|
+
image = {
|
63
|
+
:v1 => '112',
|
64
|
+
:v2 => 'e4dbdba7-b2a4-4ee5-8e8f-4595b6d694ce'
|
65
|
+
}
|
66
|
+
flavor = 2
|
67
|
+
server_list.should_not include 'test-node'
|
68
|
+
|
69
|
+
args = %W{rackspace server create -I #{image[api]} -f #{flavor} -N test-node -S test-server}
|
70
|
+
stdout, stderr, status = knife_capture(args)
|
71
|
+
status.should be(0), "Non-zero exit code.\n#{stdout}\n#{stderr}"
|
72
|
+
instance_data = capture_instance_data(stdout, {
|
73
|
+
:name => 'Name',
|
74
|
+
:instance_id => 'Instance ID',
|
75
|
+
:public_ip => 'Public IP Address',
|
76
|
+
:private_ip => 'Private IP Address'
|
77
|
+
})
|
78
|
+
|
79
|
+
# Wanted to assert active state, but got build during test
|
80
|
+
server_list.should match /#{instance_data[:instance_id]}\s*#{instance_data[:name]}\s*#{instance_data[:public_ip]}\s*#{instance_data[:private_ip]}\s*#{flavor}\s*#{image}/
|
81
|
+
|
82
|
+
args = %W{rackspace server delete #{instance_data[:instance_id]} -y}
|
83
|
+
stdout, stderr, status = knife_capture(args)
|
84
|
+
status.should be(0), "Non-zero exit code.\n#{stdout}\n#{stderr}"
|
85
|
+
|
86
|
+
# Need to deal with deleting vs deleted states before we can check this
|
87
|
+
# server_list.should_not match /#{instance_data[:instance_id]}\s*#{instance_data[:name]}\s*#{instance_data[:public_ip]}\s*#{instance_data[:private_ip]}\s*#{flavor}\s*#{image}/
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
@@ -0,0 +1,134 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'vcr'
|
3
|
+
require 'ansi/code'
|
4
|
+
require 'ansi/diff'
|
5
|
+
|
6
|
+
Chef::Config[:knife][:rackspace_api_username] = "#{ENV['OS_USERNAME']}"
|
7
|
+
Chef::Config[:knife][:rackspace_api_key] = "#{ENV['OS_PASSWORD']}"
|
8
|
+
Chef::Config[:knife][:ssl_verify_peer] = false
|
9
|
+
# Chef::Config[:knife][:rackspace_version] = "#{ENV['RS_VERSION']}"
|
10
|
+
|
11
|
+
VCR.configure do |c|
|
12
|
+
c.cassette_library_dir = 'spec/cassettes'
|
13
|
+
c.hook_into :excon
|
14
|
+
c.configure_rspec_metadata!
|
15
|
+
|
16
|
+
# Sensitive data
|
17
|
+
c.filter_sensitive_data('_RAX_USERNAME_') { Chef::Config[:knife][:rackspace_api_username] }
|
18
|
+
c.filter_sensitive_data('_RAX_PASSWORD_') { Chef::Config[:knife][:rackspace_api_key] }
|
19
|
+
c.filter_sensitive_data('_CDN-TENANT-NAME_') { ENV['RS_CDN_TENANT_NAME'] }
|
20
|
+
c.filter_sensitive_data('000000') { ENV['RS_TENANT_ID'] }
|
21
|
+
|
22
|
+
c.before_record do |interaction|
|
23
|
+
# Sensitive data
|
24
|
+
filter_headers(interaction, /X-\w*-Token/, '_ONE-TIME-TOKEN_')
|
25
|
+
|
26
|
+
# Transient data (trying to avoid unnecessary cassette churn)
|
27
|
+
filter_headers(interaction, 'X-Compute-Request-Id', '_COMPUTE-REQUEST-ID_')
|
28
|
+
filter_headers(interaction, 'X-Varnish', '_VARNISH-REQUEST-ID_')
|
29
|
+
|
30
|
+
# Throw away build state - just makes server.wait_for loops really long during replay
|
31
|
+
begin
|
32
|
+
json = JSON.parse(interaction.response.body)
|
33
|
+
if json['server']['status'] == 'BUILD'
|
34
|
+
# Ignoring interaction because server is in BUILD state
|
35
|
+
interaction.ignore!
|
36
|
+
end
|
37
|
+
rescue
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
c.before_playback do | interaction |
|
42
|
+
interaction.filter!('_TENANT-ID_', '0000000')
|
43
|
+
end
|
44
|
+
|
45
|
+
c.default_cassette_options = {
|
46
|
+
# :record => :none,
|
47
|
+
# Ignores cache busting parameters.
|
48
|
+
:match_requests_on => [:host, :path]
|
49
|
+
}
|
50
|
+
c.default_cassette_options.merge!({:record => :all}) if ENV['INTEGRATION_TESTS'] == 'live'
|
51
|
+
end
|
52
|
+
|
53
|
+
def filter_headers(interaction, pattern, placeholder)
|
54
|
+
[interaction.request.headers, interaction.response.headers].each do | headers |
|
55
|
+
sensitive_tokens = headers.select{|key| key.to_s.match(pattern)}
|
56
|
+
sensitive_tokens.each do |key, value|
|
57
|
+
headers[key] = placeholder
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
RSpec.configure do |c|
|
63
|
+
# so we can use :vcr rather than :vcr => true;
|
64
|
+
# in RSpec 3 this will no longer be necessary.
|
65
|
+
c.treat_symbols_as_metadata_keys_with_true_values = true
|
66
|
+
end
|
67
|
+
|
68
|
+
def clean_output(output)
|
69
|
+
output = ANSI.unansi(output)
|
70
|
+
output.gsub!(/\s+$/,'')
|
71
|
+
output.gsub!("\e[0G", '')
|
72
|
+
output
|
73
|
+
end
|
74
|
+
|
75
|
+
RSpec::Matchers.define :match_output do |expected_output|
|
76
|
+
match do |actual_output|
|
77
|
+
clean_output(actual_output) == expected_output.strip
|
78
|
+
end
|
79
|
+
# Nice when it works, but has ANSI::Diff has some bugs that prevent it from showing any output
|
80
|
+
failure_message_for_should do |actual_output|
|
81
|
+
puts clean_output(actual_output)
|
82
|
+
puts
|
83
|
+
puts expected_output
|
84
|
+
# output = clean_output actual_output
|
85
|
+
# ANSI::Diff.new(output, expected_output)
|
86
|
+
end
|
87
|
+
description do
|
88
|
+
'Compare actual and expected output, ignoring ansi color and trailing whitespace'
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
def server_list
|
93
|
+
stdout, stderr, status = knife_capture('rackspace server list')
|
94
|
+
status == 0 ? stdout : stderr
|
95
|
+
end
|
96
|
+
|
97
|
+
def capture_instance_data(stdout, labels = {})
|
98
|
+
result = {}
|
99
|
+
labels.each do | key, label |
|
100
|
+
result[key] = clean_output(stdout).match(/^#{label}: (.*)$/)[1]
|
101
|
+
end
|
102
|
+
result
|
103
|
+
end
|
104
|
+
|
105
|
+
# Ideally this belongs in knife-dsl, but it causes a scoping conflict with knife.rb.
|
106
|
+
# See https://github.com/chef-workflow/knife-dsl/issues/2
|
107
|
+
def knife_capture(command, args=[], input=nil)
|
108
|
+
null = Gem.win_platform? ? File.open('NUL:', 'r') : File.open('/dev/null', 'r')
|
109
|
+
|
110
|
+
if defined? Pry
|
111
|
+
Pry.config.input = STDIN
|
112
|
+
Pry.config.output = STDOUT
|
113
|
+
end
|
114
|
+
|
115
|
+
warn = $VERBOSE
|
116
|
+
$VERBOSE = nil
|
117
|
+
old_stderr, old_stdout, old_stdin = $stderr, $stdout, $stdin
|
118
|
+
|
119
|
+
$stderr = StringIO.new('', 'r+')
|
120
|
+
$stdout = StringIO.new('', 'r+')
|
121
|
+
$stdin = input ? StringIO.new(input, 'r') : null
|
122
|
+
$VERBOSE = warn
|
123
|
+
|
124
|
+
status = Chef::Knife::DSL::Support.run_knife(command, args)
|
125
|
+
return $stdout.string, $stderr.string, status
|
126
|
+
ensure
|
127
|
+
warn = $VERBOSE
|
128
|
+
$VERBOSE = nil
|
129
|
+
$stderr = old_stderr
|
130
|
+
$stdout = old_stdout
|
131
|
+
$stdin = old_stdin
|
132
|
+
$VERBOSE = warn
|
133
|
+
null.close
|
134
|
+
end
|
data/spec/spec_helper.rb
ADDED
metadata
CHANGED
@@ -1,69 +1,144 @@
|
|
1
|
-
--- !ruby/object:Gem::Specification
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
2
|
name: knife-rackspace
|
3
|
-
version: !ruby/object:Gem::Version
|
4
|
-
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.7.0
|
5
5
|
prerelease:
|
6
|
-
segments:
|
7
|
-
- 0
|
8
|
-
- 6
|
9
|
-
- 2
|
10
|
-
version: 0.6.2
|
11
6
|
platform: ruby
|
12
|
-
authors:
|
7
|
+
authors:
|
13
8
|
- Adam Jacob
|
14
9
|
- Seth Chisamore
|
15
10
|
- Matt Ray
|
16
11
|
autorequire:
|
17
12
|
bindir: bin
|
18
13
|
cert_chain: []
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
- !ruby/object:Gem::Dependency
|
14
|
+
date: 2013-06-07 00:00:00.000000000 Z
|
15
|
+
dependencies:
|
16
|
+
- !ruby/object:Gem::Dependency
|
23
17
|
name: fog
|
18
|
+
requirement: !ruby/object:Gem::Requirement
|
19
|
+
none: false
|
20
|
+
requirements:
|
21
|
+
- - ~>
|
22
|
+
- !ruby/object:Gem::Version
|
23
|
+
version: '1.6'
|
24
|
+
type: :runtime
|
24
25
|
prerelease: false
|
25
|
-
|
26
|
+
version_requirements: !ruby/object:Gem::Requirement
|
26
27
|
none: false
|
27
|
-
requirements:
|
28
|
+
requirements:
|
28
29
|
- - ~>
|
29
|
-
- !ruby/object:Gem::Version
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
30
|
+
- !ruby/object:Gem::Version
|
31
|
+
version: '1.6'
|
32
|
+
- !ruby/object:Gem::Dependency
|
33
|
+
name: knife-windows
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
35
|
+
none: false
|
36
|
+
requirements:
|
37
|
+
- - ! '>='
|
38
|
+
- !ruby/object:Gem::Version
|
39
|
+
version: '0'
|
35
40
|
type: :runtime
|
36
|
-
version_requirements: *id001
|
37
|
-
- !ruby/object:Gem::Dependency
|
38
|
-
name: chef
|
39
41
|
prerelease: false
|
40
|
-
|
42
|
+
version_requirements: !ruby/object:Gem::Requirement
|
41
43
|
none: false
|
42
|
-
requirements:
|
43
|
-
- -
|
44
|
-
- !ruby/object:Gem::Version
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
44
|
+
requirements:
|
45
|
+
- - ! '>='
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
- !ruby/object:Gem::Dependency
|
49
|
+
name: chef
|
50
|
+
requirement: !ruby/object:Gem::Requirement
|
51
|
+
none: false
|
52
|
+
requirements:
|
53
|
+
- - ! '>='
|
54
|
+
- !ruby/object:Gem::Version
|
50
55
|
version: 0.10.10
|
51
56
|
type: :runtime
|
52
|
-
|
57
|
+
prerelease: false
|
58
|
+
version_requirements: !ruby/object:Gem::Requirement
|
59
|
+
none: false
|
60
|
+
requirements:
|
61
|
+
- - ! '>='
|
62
|
+
- !ruby/object:Gem::Version
|
63
|
+
version: 0.10.10
|
64
|
+
- !ruby/object:Gem::Dependency
|
65
|
+
name: rspec
|
66
|
+
requirement: !ruby/object:Gem::Requirement
|
67
|
+
none: false
|
68
|
+
requirements:
|
69
|
+
- - ! '>='
|
70
|
+
- !ruby/object:Gem::Version
|
71
|
+
version: '0'
|
72
|
+
type: :development
|
73
|
+
prerelease: false
|
74
|
+
version_requirements: !ruby/object:Gem::Requirement
|
75
|
+
none: false
|
76
|
+
requirements:
|
77
|
+
- - ! '>='
|
78
|
+
- !ruby/object:Gem::Version
|
79
|
+
version: '0'
|
80
|
+
- !ruby/object:Gem::Dependency
|
81
|
+
name: vcr
|
82
|
+
requirement: !ruby/object:Gem::Requirement
|
83
|
+
none: false
|
84
|
+
requirements:
|
85
|
+
- - ! '>='
|
86
|
+
- !ruby/object:Gem::Version
|
87
|
+
version: '0'
|
88
|
+
type: :development
|
89
|
+
prerelease: false
|
90
|
+
version_requirements: !ruby/object:Gem::Requirement
|
91
|
+
none: false
|
92
|
+
requirements:
|
93
|
+
- - ! '>='
|
94
|
+
- !ruby/object:Gem::Version
|
95
|
+
version: '0'
|
96
|
+
- !ruby/object:Gem::Dependency
|
97
|
+
name: ansi
|
98
|
+
requirement: !ruby/object:Gem::Requirement
|
99
|
+
none: false
|
100
|
+
requirements:
|
101
|
+
- - ! '>='
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: '0'
|
104
|
+
type: :development
|
105
|
+
prerelease: false
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
107
|
+
none: false
|
108
|
+
requirements:
|
109
|
+
- - ! '>='
|
110
|
+
- !ruby/object:Gem::Version
|
111
|
+
version: '0'
|
112
|
+
- !ruby/object:Gem::Dependency
|
113
|
+
name: rake
|
114
|
+
requirement: !ruby/object:Gem::Requirement
|
115
|
+
none: false
|
116
|
+
requirements:
|
117
|
+
- - ! '>='
|
118
|
+
- !ruby/object:Gem::Version
|
119
|
+
version: '0'
|
120
|
+
type: :development
|
121
|
+
prerelease: false
|
122
|
+
version_requirements: !ruby/object:Gem::Requirement
|
123
|
+
none: false
|
124
|
+
requirements:
|
125
|
+
- - ! '>='
|
126
|
+
- !ruby/object:Gem::Version
|
127
|
+
version: '0'
|
53
128
|
description: Rackspace Support for Chef's Knife Command
|
54
|
-
email:
|
129
|
+
email:
|
55
130
|
- adam@opscode.com
|
56
131
|
- schisamo@opscode.com
|
57
132
|
- matt@opscode.com
|
58
133
|
executables: []
|
59
|
-
|
60
134
|
extensions: []
|
61
|
-
|
62
|
-
extra_rdoc_files:
|
135
|
+
extra_rdoc_files:
|
63
136
|
- README.rdoc
|
64
137
|
- LICENSE
|
65
|
-
files:
|
138
|
+
files:
|
139
|
+
- .chef/knife.rb
|
66
140
|
- .gitignore
|
141
|
+
- .travis.yml
|
67
142
|
- CHANGELOG.md
|
68
143
|
- Gemfile
|
69
144
|
- LICENSE
|
@@ -73,42 +148,49 @@ files:
|
|
73
148
|
- lib/chef/knife/rackspace_base.rb
|
74
149
|
- lib/chef/knife/rackspace_flavor_list.rb
|
75
150
|
- lib/chef/knife/rackspace_image_list.rb
|
151
|
+
- lib/chef/knife/rackspace_network_create.rb
|
152
|
+
- lib/chef/knife/rackspace_network_delete.rb
|
153
|
+
- lib/chef/knife/rackspace_network_list.rb
|
76
154
|
- lib/chef/knife/rackspace_server_create.rb
|
77
155
|
- lib/chef/knife/rackspace_server_delete.rb
|
78
156
|
- lib/chef/knife/rackspace_server_list.rb
|
79
157
|
- lib/knife-rackspace/version.rb
|
158
|
+
- spec/cassettes/v1/should_list_images.yml
|
159
|
+
- spec/cassettes/v1/should_list_server_flavors.yml
|
160
|
+
- spec/cassettes/v2/should_list_images.yml
|
161
|
+
- spec/cassettes/v2/should_list_server_flavors.yml
|
162
|
+
- spec/integration/integration_spec.rb
|
163
|
+
- spec/integration_spec_helper.rb
|
164
|
+
- spec/spec_helper.rb
|
80
165
|
homepage: http://wiki.opscode.com/display/chef
|
81
166
|
licenses: []
|
82
|
-
|
83
167
|
post_install_message:
|
84
168
|
rdoc_options: []
|
85
|
-
|
86
|
-
require_paths:
|
169
|
+
require_paths:
|
87
170
|
- lib
|
88
|
-
required_ruby_version: !ruby/object:Gem::Requirement
|
171
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
89
172
|
none: false
|
90
|
-
requirements:
|
91
|
-
- -
|
92
|
-
- !ruby/object:Gem::Version
|
93
|
-
|
94
|
-
|
95
|
-
- 0
|
96
|
-
version: "0"
|
97
|
-
required_rubygems_version: !ruby/object:Gem::Requirement
|
173
|
+
requirements:
|
174
|
+
- - ! '>='
|
175
|
+
- !ruby/object:Gem::Version
|
176
|
+
version: '0'
|
177
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
98
178
|
none: false
|
99
|
-
requirements:
|
100
|
-
- -
|
101
|
-
- !ruby/object:Gem::Version
|
102
|
-
|
103
|
-
segments:
|
104
|
-
- 0
|
105
|
-
version: "0"
|
179
|
+
requirements:
|
180
|
+
- - ! '>='
|
181
|
+
- !ruby/object:Gem::Version
|
182
|
+
version: '0'
|
106
183
|
requirements: []
|
107
|
-
|
108
184
|
rubyforge_project:
|
109
|
-
rubygems_version: 1.8.
|
185
|
+
rubygems_version: 1.8.25
|
110
186
|
signing_key:
|
111
187
|
specification_version: 3
|
112
188
|
summary: Rackspace Support for Chef's Knife Command
|
113
|
-
test_files:
|
114
|
-
|
189
|
+
test_files:
|
190
|
+
- spec/cassettes/v1/should_list_images.yml
|
191
|
+
- spec/cassettes/v1/should_list_server_flavors.yml
|
192
|
+
- spec/cassettes/v2/should_list_images.yml
|
193
|
+
- spec/cassettes/v2/should_list_server_flavors.yml
|
194
|
+
- spec/integration/integration_spec.rb
|
195
|
+
- spec/integration_spec_helper.rb
|
196
|
+
- spec/spec_helper.rb
|