holepunch 1.0.1 → 1.1.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.
- checksums.yaml +4 -4
- data/bin/holepunch +1 -1
- data/lib/holepunch.rb +20 -0
- data/lib/holepunch/cli.rb +62 -64
- data/lib/holepunch/definition.rb +20 -0
- data/lib/holepunch/dsl.rb +71 -46
- data/lib/holepunch/version.rb +1 -1
- metadata +52 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA1:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ee112b10944d69873e6fcf50a0b1536a5adfb88c
|
|
4
|
+
data.tar.gz: f40ac7918db97e6fd64470b40358fe344d456380
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4330a09b9e4e9fc291c3910395bb160bf97a94ba52404113eb6cee6386fe9a9ceadbfd60296c8f5e2efd83f389ae80978d1ec764afee411bc919447e3ae93764
|
|
7
|
+
data.tar.gz: 828ad00eb16a838923cd72be646348ac4745bee98bdadd19d4faf7c07d5a5322db96edba8cfd06881b40755c7346ca81b41bd458b5dc7718264b56fb24ba2cab
|
data/bin/holepunch
CHANGED
data/lib/holepunch.rb
CHANGED
|
@@ -33,8 +33,28 @@ module HolePunch
|
|
|
33
33
|
class GroupDoesNotExistError < HolePunchError; end
|
|
34
34
|
class SecurityGroupsFileNotFoundError < HolePunchError; end
|
|
35
35
|
class SecurityGroupsFileError < HolePunchError; end
|
|
36
|
+
class ServiceDoesNotExistError < HolePunchError; end
|
|
36
37
|
|
|
37
38
|
class << self
|
|
39
|
+
# Examines the given SecurityGroups file for the given service and returns
|
|
40
|
+
# a list of the security groups that make up that service.
|
|
41
|
+
#
|
|
42
|
+
# @param filename [String] the path to the SecurityGroups file
|
|
43
|
+
# @param env [String, nil] the environment
|
|
44
|
+
# @param name [String] the name of the service to query
|
|
45
|
+
#
|
|
46
|
+
# @return [Array<String>] the list of security group names
|
|
47
|
+
def service_groups(filename, env, name)
|
|
48
|
+
definition = Definition.build(filename, env)
|
|
49
|
+
service = definition.services[name]
|
|
50
|
+
raise ServiceDoesNotExistError, "service '#{name}' not found" if service.nil?
|
|
51
|
+
service.groups
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
#
|
|
55
|
+
# private helpers
|
|
56
|
+
#
|
|
57
|
+
|
|
38
58
|
def cidr?(value)
|
|
39
59
|
value.to_s =~ /\d+\.\d+\.\d+\.\d+\/\d+/
|
|
40
60
|
end
|
data/lib/holepunch/cli.rb
CHANGED
|
@@ -19,33 +19,41 @@
|
|
|
19
19
|
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
20
20
|
#
|
|
21
21
|
require 'holepunch'
|
|
22
|
-
require '
|
|
22
|
+
require 'thor'
|
|
23
23
|
|
|
24
24
|
module HolePunch
|
|
25
|
-
class
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
:aws_secret_access_key,
|
|
29
|
-
:env,
|
|
30
|
-
:filename,
|
|
31
|
-
:verbose
|
|
32
|
-
); end
|
|
33
|
-
|
|
34
|
-
class Cli
|
|
35
|
-
def initialize
|
|
25
|
+
class CLI < Thor
|
|
26
|
+
def initialize(*args)
|
|
27
|
+
super
|
|
36
28
|
Logger.output = LoggerOutputStdio.new
|
|
37
29
|
end
|
|
38
30
|
|
|
39
|
-
|
|
40
|
-
opts = parse_opts(args)
|
|
41
|
-
Logger.verbose = opts.verbose
|
|
31
|
+
default_task :apply
|
|
42
32
|
|
|
43
|
-
|
|
33
|
+
option :'aws-access-key', aliases: :A, type: :string, default: ENV['AWS_ACCESS_KEY_ID'], desc:
|
|
34
|
+
'Your AWS Access Key ID'
|
|
35
|
+
option :'aws-secret-access-key', aliases: :k, type: :string, default: ENV['AWS_SECRET_ACCESS_KEY'], desc:
|
|
36
|
+
'Your AWS API Secret Access Key'
|
|
37
|
+
option :'aws-region', aliases: :r, type: :string, default: ENV['AWS_REGION'], desc:
|
|
38
|
+
'Your AWS region'
|
|
39
|
+
option :env, aliases: :e, type: :string, desc:
|
|
40
|
+
'Set the environment'
|
|
41
|
+
option :file, aliases: :f, type: :string, default: "#{Dir.pwd}/SecurityGroups", desc:
|
|
42
|
+
'The location of the SecurityGroups file to use'
|
|
43
|
+
option :verbose, aliases: :v, type: :boolean, desc:
|
|
44
|
+
'Enable verbose output'
|
|
45
|
+
desc 'apply [OPTIONS]', 'apply the defined security groups to ec2'
|
|
46
|
+
def apply
|
|
47
|
+
Logger.fatal("AWS Access Key ID not defined. Use --aws-access-key or AWS_ACCESS_KEY_ID") if options[:'aws-access-key'].nil?
|
|
48
|
+
Logger.fatal("AWS Secret Access Key not defined. Use --aws-secret-access-key or AWS_SECRET_ACCESS_KEY") if options[:'aws-secret-access-key'].nil?
|
|
49
|
+
Logger.fatal("AWS Region not defined. Use --aws-region or AWS_REGION") if options[:'aws-region'].nil?
|
|
50
|
+
Logger.verbose = options[:verbose]
|
|
44
51
|
|
|
52
|
+
definition = Definition.build(options[:file], options[:env])
|
|
45
53
|
ec2 = EC2.new({
|
|
46
|
-
access_key_id:
|
|
47
|
-
secret_access_key:
|
|
48
|
-
region:
|
|
54
|
+
access_key_id: options[:'aws-access-key'],
|
|
55
|
+
secret_access_key: options[:'aws-secret-access-key'],
|
|
56
|
+
region: options[:'aws-region'],
|
|
49
57
|
})
|
|
50
58
|
ec2.apply(definition)
|
|
51
59
|
|
|
@@ -55,55 +63,45 @@ module HolePunch
|
|
|
55
63
|
Logger.fatal(e.message)
|
|
56
64
|
end
|
|
57
65
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
66
|
+
option :env, aliases: :e, type: :string, desc:
|
|
67
|
+
'Set the environment'
|
|
68
|
+
option :file, aliases: :f, type: :string, default: "#{Dir.pwd}/SecurityGroups", desc:
|
|
69
|
+
'The location of the SecurityGroups file to use'
|
|
70
|
+
option :list, type: :boolean, desc:
|
|
71
|
+
'List all services instead'
|
|
72
|
+
option :verbose, aliases: :v, type: :boolean, desc:
|
|
73
|
+
'Enable verbose output'
|
|
74
|
+
desc 'service NAME', 'output the list of security groups for a service'
|
|
75
|
+
def service(name = nil)
|
|
76
|
+
Logger.verbose = options[:verbose]
|
|
67
77
|
|
|
68
|
-
|
|
69
|
-
Usage: holepunch [options]
|
|
78
|
+
definition = Definition.build(options[:file], options[:env])
|
|
70
79
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
parser.on('-f', '--file FILENAME', String, 'The location of the SecurityGroups file to use') do |value|
|
|
81
|
-
opts.filename = value
|
|
82
|
-
end
|
|
83
|
-
parser.on('-K', '--aws-secret-access-key SECRET', String, 'Your AWS API Secret Access Key') do |value|
|
|
84
|
-
opts.aws_secret_access_key = value
|
|
85
|
-
end
|
|
86
|
-
parser.on('-r', '--aws-region REGION', String, 'Your AWS region') do |v|
|
|
87
|
-
opts.aws_region = v
|
|
88
|
-
end
|
|
89
|
-
parser.on('-v', '--verbose', 'verbose output') do |v|
|
|
90
|
-
opts.verbose = v
|
|
91
|
-
end
|
|
92
|
-
parser.on('-V', '--version', 'display the version and exit') do
|
|
93
|
-
puts VERSION
|
|
94
|
-
exit
|
|
95
|
-
end
|
|
96
|
-
parser.on_tail('-h', '--help', 'show this message') do
|
|
97
|
-
puts parser
|
|
98
|
-
exit
|
|
99
|
-
end
|
|
100
|
-
end.parse!(args)
|
|
80
|
+
if options[:list]
|
|
81
|
+
definition.services.keys.sort.each do |name|
|
|
82
|
+
puts name
|
|
83
|
+
end
|
|
84
|
+
else
|
|
85
|
+
service = definition.services[name]
|
|
86
|
+
Logger.fatal("service '#{name}' not found") if service.nil?
|
|
87
|
+
puts service.groups.sort.join(',')
|
|
88
|
+
end
|
|
101
89
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
90
|
+
rescue EnvNotDefinedError => e
|
|
91
|
+
Logger.fatal('You have security groups that use an environment, but you did not specify one. See --help')
|
|
92
|
+
rescue HolePunchError => e
|
|
93
|
+
Logger.fatal(e.message)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
desc 'version', 'display the version and exit'
|
|
97
|
+
def version
|
|
98
|
+
puts VERSION
|
|
99
|
+
end
|
|
100
|
+
map %w(-V --version) => :version
|
|
105
101
|
|
|
106
|
-
|
|
102
|
+
protected
|
|
103
|
+
def exit_on_failure?
|
|
104
|
+
true
|
|
107
105
|
end
|
|
108
106
|
end
|
|
109
107
|
end
|
data/lib/holepunch/definition.rb
CHANGED
|
@@ -19,6 +19,15 @@
|
|
|
19
19
|
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
20
20
|
#
|
|
21
21
|
module HolePunch
|
|
22
|
+
class Service
|
|
23
|
+
attr_accessor :id, :groups
|
|
24
|
+
|
|
25
|
+
def initialize(id)
|
|
26
|
+
@id = id
|
|
27
|
+
@groups = []
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
22
31
|
class SecurityGroup
|
|
23
32
|
attr_accessor :id, :desc, :dependency, :ingresses
|
|
24
33
|
|
|
@@ -59,6 +68,7 @@ module HolePunch
|
|
|
59
68
|
class Definition
|
|
60
69
|
attr_reader :env
|
|
61
70
|
attr_reader :groups
|
|
71
|
+
attr_reader :services
|
|
62
72
|
|
|
63
73
|
class << self
|
|
64
74
|
def build(file, env)
|
|
@@ -74,6 +84,7 @@ module HolePunch
|
|
|
74
84
|
def initialize(env = nil)
|
|
75
85
|
@env = env
|
|
76
86
|
@groups = {}
|
|
87
|
+
@services = {}
|
|
77
88
|
end
|
|
78
89
|
|
|
79
90
|
def add_group(group)
|
|
@@ -93,6 +104,15 @@ module HolePunch
|
|
|
93
104
|
end
|
|
94
105
|
end
|
|
95
106
|
end
|
|
107
|
+
|
|
108
|
+
# verify service group references are defined
|
|
109
|
+
services.each do |name, service|
|
|
110
|
+
service.groups.each do |group|
|
|
111
|
+
unless groups.include?(group)
|
|
112
|
+
raise GroupDoesNotExistError, "group '#{group}' referenced by service '#{name}' does not exist"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
96
116
|
end
|
|
97
117
|
end
|
|
98
118
|
end
|
data/lib/holepunch/dsl.rb
CHANGED
|
@@ -21,79 +21,104 @@
|
|
|
21
21
|
require 'pathname'
|
|
22
22
|
|
|
23
23
|
module HolePunch
|
|
24
|
-
class
|
|
25
|
-
|
|
24
|
+
class BaseDSL
|
|
25
|
+
def initialize(env, model)
|
|
26
|
+
@env = env
|
|
27
|
+
@model = model
|
|
28
|
+
end
|
|
26
29
|
|
|
27
|
-
def
|
|
28
|
-
|
|
30
|
+
def eval_dsl(filename = nil, &block)
|
|
31
|
+
if !filename.nil?
|
|
32
|
+
instance_eval(HolePunch.read_file(filename.to_s), filename.to_s, 1)
|
|
33
|
+
else
|
|
34
|
+
instance_eval(&block) if block_given?
|
|
35
|
+
end
|
|
36
|
+
@model
|
|
29
37
|
end
|
|
30
38
|
|
|
31
|
-
def
|
|
32
|
-
|
|
33
|
-
@
|
|
34
|
-
@groups = {}
|
|
39
|
+
def env
|
|
40
|
+
raise EnvNotDefinedError, 'env not defined' if @env.nil?
|
|
41
|
+
@env
|
|
35
42
|
end
|
|
43
|
+
end
|
|
36
44
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
@definition
|
|
41
|
-
rescue SyntaxError => e
|
|
42
|
-
raise SecurityGroupsFileError, "SecurityGroups syntax error #{e.message.gsub("#{filename.to_s}:", 'on line ')}"
|
|
45
|
+
class ServiceDSL < BaseDSL
|
|
46
|
+
def self.evaluate(env, *args, &block)
|
|
47
|
+
new(env, *args).eval_dsl(&block)
|
|
43
48
|
end
|
|
44
49
|
|
|
45
|
-
def env
|
|
46
|
-
|
|
47
|
-
@definition.env
|
|
50
|
+
def initialize(env, id)
|
|
51
|
+
super(env, Service.new(id))
|
|
48
52
|
end
|
|
49
53
|
|
|
50
|
-
def
|
|
51
|
-
|
|
52
|
-
raise GroupError, "duplicate group id #{id}" if @definition.groups.include?(id)
|
|
53
|
-
raise HolePunchSyntaxError, "dependency group #{id} cannot have a block" if block_given?
|
|
54
|
-
@group = SecurityGroup.new(id, dependency: true)
|
|
55
|
-
@definition.add_group(@group)
|
|
56
|
-
yield if block_given?
|
|
57
|
-
ensure
|
|
58
|
-
@group = nil
|
|
54
|
+
def groups(*ids)
|
|
55
|
+
@model.groups.concat(ids.flatten)
|
|
59
56
|
end
|
|
57
|
+
end
|
|
60
58
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
@group = nil
|
|
59
|
+
class GroupDSL < BaseDSL
|
|
60
|
+
def self.evaluate(env, *args, &block)
|
|
61
|
+
new(env, *args).eval_dsl(&block)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def initialize(env, id)
|
|
65
|
+
super(env, SecurityGroup.new(id, dependency: false))
|
|
69
66
|
end
|
|
70
67
|
|
|
71
68
|
def desc(str)
|
|
72
|
-
|
|
73
|
-
raise HolePunchSyntaxError, 'desc cannot be used in a dependency group (the group is expected to be already defined elsewhere)' if @group.dependency
|
|
74
|
-
@group.desc = str
|
|
69
|
+
@model.desc = str
|
|
75
70
|
end
|
|
76
71
|
|
|
77
72
|
def icmp(*sources)
|
|
78
|
-
raise HolePunchSyntaxError, 'ping/icmp must be used inside a group' if @group.nil?
|
|
79
|
-
raise HolePunchSyntaxError, 'ping/icmp cannot be used in a dependency group (the group is expected to be already defined elsewhere)' if @group.dependency
|
|
80
73
|
sources << '0.0.0.0/0' if sources.empty?
|
|
81
|
-
@
|
|
74
|
+
@model.ingresses << Permission.new(:icmp, nil, sources.flatten)
|
|
82
75
|
end
|
|
83
76
|
alias_method :ping, :icmp
|
|
84
77
|
|
|
85
78
|
def tcp(ports, *sources)
|
|
86
|
-
raise HolePunchSyntaxError, 'tcp must be used inside a group' if @group.nil?
|
|
87
|
-
raise HolePunchSyntaxError, 'tcp cannot be used in a dependency group (the group is expected to be already defined elsewhere)' if @group.dependency
|
|
88
79
|
sources << '0.0.0.0/0' if sources.empty?
|
|
89
|
-
@
|
|
80
|
+
@model.ingresses << Permission.new(:tcp, ports, sources.flatten)
|
|
90
81
|
end
|
|
91
82
|
|
|
92
83
|
def udp(ports, *sources)
|
|
93
|
-
raise HolePunchSyntaxError, 'udp must be used inside a group' if @group.nil?
|
|
94
|
-
raise HolePunchSyntaxError, 'udp cannot be used in a dependency group (the group is expected to be already defined elsewhere)' if @group.dependency
|
|
95
84
|
sources << '0.0.0.0/0' if sources.empty?
|
|
96
|
-
@
|
|
85
|
+
@model.ingresses << Permission.new(:udp, ports, sources.flatten)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
class DSL < BaseDSL
|
|
90
|
+
def self.evaluate(filename, env)
|
|
91
|
+
DSL.new(env).eval_dsl(filename)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def initialize(env)
|
|
95
|
+
super(env, Definition.new(env))
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def eval_dsl(filename)
|
|
99
|
+
super(filename)
|
|
100
|
+
@model.validate!
|
|
101
|
+
@model
|
|
102
|
+
rescue SyntaxError => e
|
|
103
|
+
raise SecurityGroupsFileError, "SecurityGroups syntax error #{e.message.gsub("#{filename.to_s}:", 'on line ')}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def depends(id)
|
|
107
|
+
id = id.to_s
|
|
108
|
+
raise GroupError, "duplicate group id #{id}" if @model.groups.include?(id)
|
|
109
|
+
raise HolePunchSyntaxError, "dependency group #{id} cannot have a block" if block_given?
|
|
110
|
+
@model.add_group(SecurityGroup.new(id, dependency: true))
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def group(id, &block)
|
|
114
|
+
id = id.to_s
|
|
115
|
+
raise GroupError, "duplicate group id #{id}" if @model.groups.include?(id)
|
|
116
|
+
@model.add_group(GroupDSL.evaluate(@env, id, &block))
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def service(id, &block)
|
|
120
|
+
id = id.to_s
|
|
121
|
+
@model.services[id] = ServiceDSL.evaluate(@env, id, &block)
|
|
97
122
|
end
|
|
98
123
|
end
|
|
99
124
|
end
|
data/lib/holepunch/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: holepunch
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.0
|
|
4
|
+
version: 1.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ben Scott
|
|
@@ -9,8 +9,22 @@ authors:
|
|
|
9
9
|
autorequire:
|
|
10
10
|
bindir: bin
|
|
11
11
|
cert_chain: []
|
|
12
|
-
date: 2014-
|
|
12
|
+
date: 2014-07-01 00:00:00.000000000 Z
|
|
13
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.19'
|
|
21
|
+
type: :runtime
|
|
22
|
+
prerelease: false
|
|
23
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
24
|
+
requirements:
|
|
25
|
+
- - "~>"
|
|
26
|
+
- !ruby/object:Gem::Version
|
|
27
|
+
version: '0.19'
|
|
14
28
|
- !ruby/object:Gem::Dependency
|
|
15
29
|
name: aws-sdk
|
|
16
30
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -119,9 +133,9 @@ description: |
|
|
|
119
133
|
This allows you to have custom security groups per server environment.
|
|
120
134
|
|
|
121
135
|
```ruby
|
|
122
|
-
group "
|
|
123
|
-
group "
|
|
124
|
-
tcp 5432, "
|
|
136
|
+
group "#{env}-web"
|
|
137
|
+
group "#{env}-db" do
|
|
138
|
+
tcp 5432, "#{env}-web"
|
|
125
139
|
end
|
|
126
140
|
```
|
|
127
141
|
|
|
@@ -143,6 +157,26 @@ description: |
|
|
|
143
157
|
end
|
|
144
158
|
```
|
|
145
159
|
|
|
160
|
+
You can specify ping/icmp rules with `icmp` (alias: `ping`).
|
|
161
|
+
|
|
162
|
+
```ruby
|
|
163
|
+
group 'my-service' do
|
|
164
|
+
ping '10.0.0.0/16'
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
It can be useful to describe groups of security groups you plan to launch
|
|
168
|
+
instances with by using the `service` declaration.
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
service "#{env}-web" do
|
|
172
|
+
groups %W(
|
|
173
|
+
admin
|
|
174
|
+
#{env}-log-producer
|
|
175
|
+
#{env}-web
|
|
176
|
+
)
|
|
177
|
+
end
|
|
178
|
+
```
|
|
179
|
+
|
|
146
180
|
## Usage
|
|
147
181
|
|
|
148
182
|
Simply navigate to the directory containing your `SecurityGroups` file and run `holepunch`.
|
|
@@ -157,6 +191,19 @@ description: |
|
|
|
157
191
|
$ holepunch -e live
|
|
158
192
|
```
|
|
159
193
|
|
|
194
|
+
You can get a list of security groups for a service using the `service` subcommand.
|
|
195
|
+
|
|
196
|
+
```
|
|
197
|
+
$ holepunch service -e prod prod-web
|
|
198
|
+
admin,prod-log-producer,prod-web
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
You can also get a list of all defined services.
|
|
202
|
+
|
|
203
|
+
```
|
|
204
|
+
$ holepunch service --list
|
|
205
|
+
```
|
|
206
|
+
|
|
160
207
|
## Testing
|
|
161
208
|
|
|
162
209
|
You can run the unit tests by simply running rspec.
|