pipette 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/.document ADDED
@@ -0,0 +1,5 @@
1
+ README.rdoc
2
+ lib/**/*.rb
3
+ bin/*
4
+ features/**/*.feature
5
+ LICENSE
data/.gitignore ADDED
@@ -0,0 +1,22 @@
1
+ ## MAC OS
2
+ .DS_Store
3
+
4
+ ## TEXTMATE
5
+ *.tmproj
6
+ tmtags
7
+
8
+ ## EMACS
9
+ *~
10
+ \#*
11
+ .\#*
12
+
13
+ ## VIM
14
+ *.swp
15
+
16
+ ## PROJECT::GENERAL
17
+ coverage
18
+ rdoc
19
+ pkg
20
+ .bundle
21
+
22
+ ## PROJECT::SPECIFIC
data/Gemfile ADDED
@@ -0,0 +1,15 @@
1
+ # A sample Gemfile
2
+ source :gemcutter
3
+ #
4
+ # gem "rails"
5
+
6
+ gem "right_aws"
7
+ gem "trollop"
8
+
9
+ group :test do
10
+ gem "rspec"
11
+ end
12
+
13
+ group :dev do
14
+ gem "yard"
15
+ end
data/Gemfile.lock ADDED
@@ -0,0 +1,18 @@
1
+ GEM
2
+ remote: http://rubygems.org/
3
+ specs:
4
+ right_aws (2.0.0)
5
+ right_http_connection (>= 1.2.1)
6
+ right_http_connection (1.2.4)
7
+ rspec (1.3.0)
8
+ trollop (1.16.2)
9
+ yard (0.5.8)
10
+
11
+ PLATFORMS
12
+ ruby
13
+
14
+ DEPENDENCIES
15
+ right_aws
16
+ rspec
17
+ trollop
18
+ yard
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2009 Paul Sadauskas
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 ADDED
File without changes
data/README.rdoc ADDED
@@ -0,0 +1,17 @@
1
+ = pipette
2
+
3
+ Description goes here.
4
+
5
+ == Note on Patches/Pull Requests
6
+
7
+ * Fork the project.
8
+ * Make your feature addition or bug fix.
9
+ * Add tests for it. This is important so I don't break it in a
10
+ future version unintentionally.
11
+ * Commit, do not mess with rakefile, version, or history.
12
+ (if you want to have your own version, that is fine but bump version in a commit by itself I can ignore when I pull)
13
+ * Send me a pull request. Bonus points for topic branches.
14
+
15
+ == Copyright
16
+
17
+ Copyright (c) 2010 Paul Sadauskas. See LICENSE for details.
data/Rakefile ADDED
@@ -0,0 +1,54 @@
1
+ require 'rubygems'
2
+ require 'rake'
3
+
4
+ begin
5
+ require 'jeweler'
6
+ Jeweler::Tasks.new do |gem|
7
+ gem.name = "pipette"
8
+ gem.summary = %Q{pipette is a tool for managing volumes}
9
+ gem.description = %Q{Create, grow, and remove ebs+md+lvm volumes and disks}
10
+ gem.email = "psadauskas@gmail.com"
11
+ gem.homepage = "http://github.com/mongomachine/pipette"
12
+ gem.authors = ["Paul Sadauskas"]
13
+
14
+ gem.add_dependency "trollop"
15
+ gem.add_dependency "right_aws"
16
+
17
+ gem.add_development_dependency "rspec", ">= 1.2.9"
18
+ gem.add_development_dependency "yard", ">= 0"
19
+ # gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
20
+ end
21
+ rescue LoadError
22
+ puts "Jeweler (or a dependency) not available. Install it with: gem install jeweler"
23
+ end
24
+
25
+ begin
26
+ require 'spec/rake/spectask'
27
+ Spec::Rake::SpecTask.new(:spec) do |spec|
28
+ spec.libs << 'lib' << 'spec'
29
+ spec.spec_files = FileList['spec/**/*_spec.rb']
30
+ end
31
+
32
+ Spec::Rake::SpecTask.new(:rcov) do |spec|
33
+ spec.libs << 'lib' << 'spec'
34
+ spec.pattern = 'spec/**/*_spec.rb'
35
+ spec.rcov = true
36
+ end
37
+
38
+ task :spec => :check_dependencies
39
+
40
+ task :default => :spec
41
+ rescue LoadError
42
+ task :spec do
43
+ abort "RSpec is not available."
44
+ end
45
+ end
46
+
47
+ begin
48
+ require 'yard'
49
+ YARD::Rake::YardocTask.new
50
+ rescue LoadError
51
+ task :yardoc do
52
+ abort "YARD is not available. In order to run yardoc, you must: sudo gem install yard"
53
+ end
54
+ end
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.1.0
data/bin/pipette ADDED
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'rubygems'
4
+ require 'trollop'
5
+
6
+ SUB_COMMANDS = %w[ init append add grow remove ]
7
+
8
+ opts = Trollop::options do
9
+ banner <<-EOS
10
+ This script will manage mirrored EBS volumes and lvm volume groups.
11
+
12
+ Usage:
13
+ pipette [global options] subcommand [options]
14
+
15
+ Where subcommand is one of #{SUB_COMMANDS.inspect}
16
+
17
+ Global Options:
18
+ EOS
19
+ opt :access_key, "AWS Access Key", :short => "-a", :default => ENV['ACCESS_KEY']
20
+ opt :secret_access_key, "AWS Secret Access Key", :short => "-c", :default => ENV['SECRET_ACCESS_KEY']
21
+ stop_on SUB_COMMANDS
22
+ end
23
+
24
+ cmd = ARGV.shift
25
+ cmd_opts = case cmd
26
+ when "init"
27
+ Trollop::options do
28
+ banner "Create an initial volume group of EBS mirrored volumes"
29
+ opt :name , "Name of volume group to create" , :short => "-n", :required => true, :type => :string
30
+ opt :size , "Size of initial volume in GB" , :short => "-s", :required => true, :type => :int
31
+ opt :ebs_per_mirror, "Number of EBS volumes to put in each mirrored disk" , :default => 2 , :type => :int
32
+ opt :num_mirrors , "Number of mirrors to create; will be spanned by the volume group", :default => 2 , :type => :int
33
+ end
34
+ when "append"
35
+ Trollop::options do
36
+ banner "Add another set of mirrored EBS volumes to the volume group"
37
+ opt :name , "Name of volume group to append to" , :short => "-n", :required => true, :type => :string
38
+ opt :size , "Size of initial volume in GB" , :short => "-s", :required => true, :type => :int
39
+ opt :ebs_per_mirror, "Number of EBS volumes to put in each mirrored disk" , :default => 2 , :type => :int
40
+ opt :num_mirrors , "Number of mirrors to create; will be spanned by the volume group", :default => 1 , :type => :int
41
+ end
42
+ when "add"
43
+ Trollop::options do
44
+ banner "Create a new logical volume in the volume group"
45
+ opt :name , "Name of new logical volume" , :short => "-n", :required => true, :type => :string
46
+ opt :volume, "Name of volume group to use" , :short => "-v", :required => true, :type => :string
47
+ opt :size , "Size of new logical volume", :short => "-s", :required => true, :type => :int
48
+ end
49
+ when "grow"
50
+ Trollop::options do
51
+ banner "Increase the size of a logical volume"
52
+ opt :name , "Name of logical volume" , :short => "-n", :required => true, :type => :string
53
+ opt :volume, "Name of volume group to use" , :short => "-v", :required => true, :type => :string
54
+ opt :size , "Size to increase logical volume to or '+n' to increase size by nGB", :short => "-s", :required => true, :type => :string
55
+ end
56
+ when "remove"
57
+ Trollop::options do
58
+ banner "Remove a logical volume"
59
+ opt :name , "Name of logical volume" , :short => "-n", :required => true, :type => :string
60
+ opt :volume, "Name of volume group to use", :short => "-v", :required => true, :type => :string
61
+ end
62
+ else
63
+ Trollop::die "unknown subcommand #{cmd.inspect}"
64
+ end
65
+
66
+ begin
67
+ require 'pipette'
68
+ rescue LoadError => ex
69
+ # Not in load path
70
+ require File.join(File.dirname(__FILE__), '..', 'lib', 'pipette')
71
+ end
72
+
73
+ Pipette.new(opts).send(cmd, cmd_opts)
74
+
75
+ # vim: ft=ruby
@@ -0,0 +1,93 @@
1
+
2
+ class Pipette
3
+ module Commands
4
+
5
+ # cmd_opts:
6
+ #
7
+ # size: Size of each volume, in GB
8
+ # name: Name of the volume group to create
9
+ # ebs_per_mirror: Number of EBS volumes to put in each mirrored disk. Default 2.
10
+ # num_mirrors: Number of mirrors to create, to be spanned by the volume group. Default 2.
11
+ def init(cmd_opts)
12
+ raise "No volume group name specified" unless cmd_opts[:name]
13
+ raise "No EBS volume size specified" unless cmd_opts[:size]
14
+
15
+ md_devs = create_mirrors(cmd_opts[:size],
16
+ cmd_opts[:num_mirrors],
17
+ cmd_opts[:ebs_per_mirror])
18
+
19
+
20
+ vg_create cmd_opts[:name], md_devs
21
+ end
22
+
23
+ def append(cmd_opts)
24
+ raise "No volume group name specified" unless cmd_opts[:name]
25
+ raise "No EBS volume size specified" unless cmd_opts[:size]
26
+
27
+ md_devs = create_mirrors(cmd_opts[:size],
28
+ cmd_opts[:num_mirrors],
29
+ cmd_opts[:ebs_per_mirror])
30
+
31
+ vg_extend cmd_opts[:name], md_devs
32
+ end
33
+
34
+ def add(cmd_opts)
35
+ raise "No logical volume name specified" unless cmd_opts[:name]
36
+ raise "No logical volume size specified" unless cmd_opts[:size]
37
+ raise "No volume group name specified" unless cmd_opts[:volume]
38
+
39
+ dev = lv_create(cmd_opts[:name], cmd_opts[:volume], cmd_opts[:size])
40
+ format(dev)
41
+ mount(dev, "/data/#{cmd_opts[:name]}")
42
+ end
43
+
44
+ def grow(cmd_opts)
45
+ raise "No logical volume name specified" unless cmd_opts[:name]
46
+ raise "No logical volume size specified" unless cmd_opts[:size]
47
+ raise "No volume group name specified" unless cmd_opts[:volume]
48
+
49
+ dev = lv_dev(cmd_opts[:volume], cmd_opts[:name])
50
+ lv_extend(dev, cmd_opts[:size])
51
+ growfs(dev)
52
+ end
53
+
54
+ def remove(cmd_opts)
55
+ raise "No logical volume name specified" unless cmd_opts[:name]
56
+ raise "No volume group name specified" unless cmd_opts[:volume]
57
+
58
+ dev = lv_dev(cmd_opts[:volume], cmd_opts[:name])
59
+ umount(dev)
60
+ lv_remove(dev)
61
+ end
62
+
63
+ protected
64
+
65
+ def create_mirrors(size, num_mirrors = 2, num_disks = 2)
66
+ md_devs = []
67
+ num_mirrors.times do
68
+ volumes = []
69
+ num_disks.times do
70
+ volumes << create_volume(size)
71
+ end
72
+
73
+ devices = []
74
+ volumes.each do |v_id|
75
+ mp = next_avail_disk
76
+ mount_volume v_id, mp
77
+ devices << mp
78
+ end
79
+
80
+ wait_for devices
81
+
82
+ md_dev = next_md
83
+ create_md_mirror md_dev, *devices
84
+
85
+ pv_create md_dev
86
+ md_devs << md_dev
87
+ end
88
+
89
+ md_devs
90
+ end
91
+
92
+ end
93
+ end
@@ -0,0 +1,36 @@
1
+ require 'open-uri'
2
+
3
+ class Pipette
4
+ module Ec2
5
+
6
+ def ec2
7
+ @ec2 ||= RightAws::Ec2.new(opts[:access_key], opts[:secret_access_key])
8
+ end
9
+
10
+ def create_volume(size)
11
+ say_with_time "Creating new volume of size #{size}G" do
12
+ res = ec2.create_volume(nil, size, zone)
13
+ res[:aws_id]
14
+ end
15
+ end
16
+
17
+ def mount_volume(volume_id, mount_point)
18
+ say_with_time "Mounting #{volume_id} at #{mount_point}" do
19
+ res = ec2.attach_volume(volume_id, instance_id, mount_point)
20
+ res[:aws_device]
21
+ end
22
+ end
23
+
24
+ def zone
25
+ @zone ||= meta("placement/availability-zone")
26
+ end
27
+
28
+ def instance_id
29
+ @instance_id ||= meta("instance-id")
30
+ end
31
+
32
+ def meta(path)
33
+ open("http://169.254.169.254/latest/meta-data/#{path}").read.strip
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,49 @@
1
+
2
+ class Pipette
3
+ module Lvm
4
+
5
+ def pv_create(device)
6
+ run_command "pvcreate", device
7
+ end
8
+
9
+ def vg_create(name, *devices)
10
+ run_command "vgcreate",
11
+ "--physicalextentsize 16M",
12
+ name,
13
+ *devices
14
+ end
15
+
16
+ def vg_extend(name, *devices)
17
+ run_command "vgextend",
18
+ name,
19
+ *devices
20
+ end
21
+
22
+ def lv_create(name, vg, size)
23
+ run_command "lvcreate",
24
+ "--size #{size}G",
25
+ "--name #{name}",
26
+ vg
27
+
28
+ lv_dev(vg, name)
29
+ end
30
+
31
+ def lv_extend(device, size)
32
+ run_command "lvextend",
33
+ "--size #{size}G",
34
+ device
35
+ end
36
+
37
+ def lv_remove(device)
38
+ run_command "lvremove", "--force", device
39
+ end
40
+
41
+ protected
42
+
43
+ def lv_dev(vg, name)
44
+ "/dev/#{vg}/#{name}"
45
+ end
46
+
47
+
48
+ end
49
+ end
data/lib/pipette/md.rb ADDED
@@ -0,0 +1,16 @@
1
+
2
+ class Pipette
3
+ module Md
4
+
5
+ def create_md_mirror(md_device, *devices)
6
+ run_command "mdadm",
7
+ "--create", md_device,
8
+ "--level=mirror",
9
+ "--raid-devices=#{devices.size}",
10
+ *devices
11
+
12
+ wait_for md_device
13
+ end
14
+
15
+ end
16
+ end
@@ -0,0 +1,76 @@
1
+
2
+ class Pipette
3
+ module Util
4
+
5
+ def run_command(cmd, *args)
6
+ args.unshift(cmd)
7
+
8
+ command = args.flatten.join(' ')
9
+
10
+ say_with_time command do
11
+ `#{command}`
12
+ end
13
+ end
14
+
15
+ def next_avail_disk
16
+ @last_disk ||= Dir["/dev/sd[d-p]{1,2,3,4,5,6,7,8,9,10,11,12,13,14,15}"].sort { |a,b|
17
+ disk_parts(a) <=> disk_parts(b)
18
+ }.last
19
+
20
+ disk, part = disk_parts(@last_disk)
21
+
22
+ if disk
23
+ if part.to_i >= 15
24
+ disk = disk.succ
25
+ part = 1
26
+ else
27
+ part += 1
28
+ end
29
+ else
30
+ disk, part = "d", "1"
31
+ end
32
+
33
+ @last_disk = "/dev/sd#{disk}#{part}"
34
+ end
35
+
36
+ def next_md
37
+ md = Dir["/dev/md[0-9]*"].sort.last
38
+ if md && match = md.match(%r{/dev/md([\d+])})
39
+ i = match[1].to_i + 1
40
+ else
41
+ i = 0
42
+ end
43
+ "/dev/md#{i}"
44
+ end
45
+
46
+ def disk_parts(dev)
47
+ if dev && match = dev.match(%r{/dev/sd([d-p])([0-1][0-9]|[0-9])})
48
+ disk, part = match[1], match[2].to_i
49
+ else
50
+ nil
51
+ end
52
+ end
53
+
54
+ def say_with_time(msg = "", &block)
55
+ @depth ||= 0
56
+ @depth += 1
57
+ indent = ' ' * @depth
58
+ puts "#{indent}#{msg}"
59
+ t = Time.now
60
+ res = yield
61
+ puts "#{indent}-> %0.4f" % (Time.now - t)
62
+ @depth -= 1
63
+ res
64
+ end
65
+
66
+ def wait_for(*paths)
67
+ paths.flatten.each do |path|
68
+ puts "Waiting for #{path} to appear..."
69
+ until File.exist?(path)
70
+ sleep 0.1
71
+ end
72
+ end
73
+ end
74
+
75
+ end
76
+ end
@@ -0,0 +1,22 @@
1
+
2
+ class Pipette
3
+ module Xfs
4
+
5
+ def format(device)
6
+ run_command "mkfs.xfs", device
7
+ end
8
+
9
+ def mount(device, mountpoint)
10
+ FileUtils.mkdir_p(mountpoint)
11
+ run_command "mount", device, mountpoint
12
+ end
13
+
14
+ def growfs(device)
15
+ run_command "xfs_growfs", device
16
+ end
17
+
18
+ def umount(device)
19
+ run_command "umount", device
20
+ end
21
+ end
22
+ end
data/lib/pipette.rb ADDED
@@ -0,0 +1,24 @@
1
+ require 'right_aws'
2
+
3
+ dir = File.dirname(__FILE__)
4
+
5
+ %w[ util ec2 md lvm xfs commands ].each do |file|
6
+ require File.join(dir, 'pipette', file)
7
+ end
8
+
9
+ class Pipette
10
+ include Util
11
+ include Ec2
12
+ include Md
13
+ include Lvm
14
+ include Xfs
15
+ include Commands
16
+
17
+ attr_reader :opts
18
+
19
+ def initialize(opts)
20
+ @opts = opts
21
+ end
22
+
23
+ end
24
+
@@ -0,0 +1,7 @@
1
+ require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
2
+
3
+ describe "Pipette" do
4
+ it "fails" do
5
+ fail "hey buddy, you should probably rename this file and start specing for real"
6
+ end
7
+ end
data/spec/spec.opts ADDED
@@ -0,0 +1 @@
1
+ --color
@@ -0,0 +1,9 @@
1
+ $LOAD_PATH.unshift(File.dirname(__FILE__))
2
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
3
+ require 'pipette'
4
+ require 'spec'
5
+ require 'spec/autorun'
6
+
7
+ Spec::Runner.configure do |config|
8
+
9
+ end
metadata ADDED
@@ -0,0 +1,146 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: pipette
3
+ version: !ruby/object:Gem::Version
4
+ hash: 27
5
+ prerelease: false
6
+ segments:
7
+ - 0
8
+ - 1
9
+ - 0
10
+ version: 0.1.0
11
+ platform: ruby
12
+ authors:
13
+ - Paul Sadauskas
14
+ autorequire:
15
+ bindir: bin
16
+ cert_chain: []
17
+
18
+ date: 2010-08-18 00:00:00 -06:00
19
+ default_executable: pipette
20
+ dependencies:
21
+ - !ruby/object:Gem::Dependency
22
+ name: trollop
23
+ prerelease: false
24
+ requirement: &id001 !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ hash: 3
30
+ segments:
31
+ - 0
32
+ version: "0"
33
+ type: :runtime
34
+ version_requirements: *id001
35
+ - !ruby/object:Gem::Dependency
36
+ name: right_aws
37
+ prerelease: false
38
+ requirement: &id002 !ruby/object:Gem::Requirement
39
+ none: false
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ hash: 3
44
+ segments:
45
+ - 0
46
+ version: "0"
47
+ type: :runtime
48
+ version_requirements: *id002
49
+ - !ruby/object:Gem::Dependency
50
+ name: rspec
51
+ prerelease: false
52
+ requirement: &id003 !ruby/object:Gem::Requirement
53
+ none: false
54
+ requirements:
55
+ - - ">="
56
+ - !ruby/object:Gem::Version
57
+ hash: 13
58
+ segments:
59
+ - 1
60
+ - 2
61
+ - 9
62
+ version: 1.2.9
63
+ type: :development
64
+ version_requirements: *id003
65
+ - !ruby/object:Gem::Dependency
66
+ name: yard
67
+ prerelease: false
68
+ requirement: &id004 !ruby/object:Gem::Requirement
69
+ none: false
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ hash: 3
74
+ segments:
75
+ - 0
76
+ version: "0"
77
+ type: :development
78
+ version_requirements: *id004
79
+ description: Create, grow, and remove ebs+md+lvm volumes and disks
80
+ email: psadauskas@gmail.com
81
+ executables:
82
+ - pipette
83
+ extensions: []
84
+
85
+ extra_rdoc_files:
86
+ - LICENSE
87
+ - README
88
+ - README.rdoc
89
+ files:
90
+ - .document
91
+ - .gitignore
92
+ - Gemfile
93
+ - Gemfile.lock
94
+ - LICENSE
95
+ - README
96
+ - README.rdoc
97
+ - Rakefile
98
+ - VERSION
99
+ - bin/pipette
100
+ - lib/pipette.rb
101
+ - lib/pipette/commands.rb
102
+ - lib/pipette/ec2.rb
103
+ - lib/pipette/lvm.rb
104
+ - lib/pipette/md.rb
105
+ - lib/pipette/util.rb
106
+ - lib/pipette/xfs.rb
107
+ - spec/pipette_spec.rb
108
+ - spec/spec.opts
109
+ - spec/spec_helper.rb
110
+ has_rdoc: true
111
+ homepage: http://github.com/mongomachine/pipette
112
+ licenses: []
113
+
114
+ post_install_message:
115
+ rdoc_options:
116
+ - --charset=UTF-8
117
+ require_paths:
118
+ - lib
119
+ required_ruby_version: !ruby/object:Gem::Requirement
120
+ none: false
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ hash: 3
125
+ segments:
126
+ - 0
127
+ version: "0"
128
+ required_rubygems_version: !ruby/object:Gem::Requirement
129
+ none: false
130
+ requirements:
131
+ - - ">="
132
+ - !ruby/object:Gem::Version
133
+ hash: 3
134
+ segments:
135
+ - 0
136
+ version: "0"
137
+ requirements: []
138
+
139
+ rubyforge_project:
140
+ rubygems_version: 1.3.7
141
+ signing_key:
142
+ specification_version: 3
143
+ summary: pipette is a tool for managing volumes
144
+ test_files:
145
+ - spec/pipette_spec.rb
146
+ - spec/spec_helper.rb