stack-kicker 0.0.10 → 0.0.11
Sign up to get free protection for your applications and to get access to all the features.
- data/.rvmrc +1 -1
- data/CHANGELOG.md +4 -0
- data/README.md +82 -28
- data/doc/examples/.rvmrc +34 -0
- data/doc/examples/Gemfile +4 -0
- data/doc/examples/apache-via-cloud-init/Stackfile +40 -0
- data/doc/examples/apache-via-cloud-init/cloud-init.sh +8 -0
- data/doc/examples/apache-via-cloud-init/cloud-init.yaml +28 -0
- data/lib/stack-kicker/version.rb +1 -1
- data/lib/stack.rb +87 -76
- metadata +9 -3
- /data/doc/examples/{Stackfile → apache-via-chef-server/Stackfile} +0 -0
data/.rvmrc
CHANGED
@@ -6,7 +6,7 @@
|
|
6
6
|
# First we specify our desired <ruby>[@<gemset>], the @gemset name is optional,
|
7
7
|
# Only full ruby name is supported here, for short names use:
|
8
8
|
# echo "rvm use 1.9.3" > .rvmrc
|
9
|
-
environment_id="ruby-1.9.3
|
9
|
+
environment_id="ruby-1.9.3@stack-kicker"
|
10
10
|
|
11
11
|
# Uncomment the following lines if you want to verify rvm version per project
|
12
12
|
# rvmrc_rvm_version="1.16.17 (stable)" # 1.10.1 seams as a safe start
|
data/CHANGELOG.md
ADDED
data/README.md
CHANGED
@@ -1,14 +1,10 @@
|
|
1
1
|
# stack-kicker
|
2
2
|
|
3
|
-
stack-kicker is a simple 'application stack' deployment tool, it's purpose in life
|
4
|
-
is to spin up a set of instances in a repeatable, controlled fashion, and optionally
|
3
|
+
stack-kicker is a simple 'application stack' deployment tool, it's purpose in life
|
4
|
+
is to spin up a set of instances in a repeatable, controlled fashion, and optionally
|
5
5
|
run post-install scripts after each instance has been started.
|
6
6
|
|
7
|
-
stack-kicker has hooks to allow default & custom cloud-init templates to be built & passed to
|
8
|
-
your compute provider (we currently use ruby-openstack, so are limited to OpenStack providers,
|
9
|
-
however, a sister project, aws-kicker, uses fog.io, the interaction with the compute provider is
|
10
|
-
minimal, so it's on the roadmap to merge aws-kicker & stack-kicker, and use either an internal
|
11
|
-
abstraction layer or just fog.io for all compute provisioning requests)
|
7
|
+
stack-kicker has hooks to allow default & custom cloud-init templates to be built & passed to your compute provider (we currently use ruby-openstack, so are limited to OpenStack providers, however, a sister project, aws-kicker, uses fog.io, the interaction with the compute provider is minimal, so it's on the roadmap to merge aws-kicker & stack-kicker, and use either an internal abstraction layer or just fog.io for all compute provisioning requests)
|
12
8
|
|
13
9
|
## Stackfile
|
14
10
|
Normally, stack configurations are stored in a Stackfile, which is a ruby hash of configuration options.
|
@@ -24,14 +20,72 @@ stack-kicker sequentially iterates over defined roles, creating the required num
|
|
24
20
|
Hostnames are generated from a customizable template, which is effectively:
|
25
21
|
|
26
22
|
config[:name_template] = '%s-%s-%s%04d'
|
27
|
-
config[
|
23
|
+
config[:global_service_name] = 'myapp'
|
28
24
|
site = <derived from region/az, via config[:site_template]>
|
29
|
-
hostname = sprintf(config[:name_template], config[
|
25
|
+
hostname = sprintf(config[:name_template], config[:global_service_name], site, role, position)
|
30
26
|
|
31
|
-
So hostnames will be myapp-az1-chef0001, myapp-az1-web0001, myapp-az1-web0002 etc.
|
27
|
+
So hostnames will be myapp-az1-chef0001, myapp-az1-web0001, myapp-az1-web0002 etc.
|
32
28
|
|
33
29
|
post-install scripts are executed from the same host as stack-kicker is being used, using the same credentials as the current user. They are can be used to retrieve information from a freshly built node (like certificates from a chef server), so block progress until the chef-client run has completed (we use this to block percona/galera & rabbitmq cluster builds so that the first node is up & running correctly before we try and add another node to the cluster)
|
34
30
|
|
31
|
+
### [Role Attributes](id:role_attributes)
|
32
|
+
Roles have several attributes, which control how & how many nodes are created, and how they are created. Below shows the default values for these attributes, which can all be overridden.
|
33
|
+
|
34
|
+
:role_name = {
|
35
|
+
:count => 1,
|
36
|
+
:azs[] => config['REGION']
|
37
|
+
|
38
|
+
:chef_server => false,
|
39
|
+
:skip_chef_prereg => false,
|
40
|
+
:security_group => :role_name.to_s,
|
41
|
+
|
42
|
+
:cloud_config_yaml => 'cloud-config.yaml',
|
43
|
+
:bootstrap => 'chef-client-bootstrap-excl-validation-pem.sh',
|
44
|
+
:data_dir => '/dummy',
|
45
|
+
|
46
|
+
:floating_ips => nil
|
47
|
+
|
48
|
+
:post_install_script => nil,
|
49
|
+
:post_install_args => '',
|
50
|
+
:post_install_cwd => '/.'
|
51
|
+
}
|
52
|
+
|
53
|
+
#### :count
|
54
|
+
The number of nodes or instances of this role that will be created.
|
55
|
+
#### :azs
|
56
|
+
This can be an array of strings, so that nodes will be placed in specific availability zones. If no array is set, the REGION set in the global section will be used.
|
57
|
+
#### :chef_server
|
58
|
+
this is flag used to denote if the node created by this role should be used as a chef server for nodes created by other roles. If this flag is set, we extract the public & private IP address for use later, as well downloading validation.pem & creating a user account in chef & downloading the pen (this is done via chef-post-install.sh, or your own alternative methods)
|
59
|
+
#### :skip_chef_prereg
|
60
|
+
This is usually only used when :chef_server = true, it stops stack-kicker from attempting to pre-create the chef client & node and applying roles to the node.
|
61
|
+
#### :security_group
|
62
|
+
security group to be assigned to this node. (set to default is you don't want to manage security groups for every role)
|
63
|
+
#### :cloud_config_yaml
|
64
|
+
defaults to a file which contains a simple template (lib/cloud-config.yaml in the github repo) that installs the http://apt.opscode.com repo & gig key, as well as installing the opscode-keyring. Can be replaced with any filename that complies with cloud-init.
|
65
|
+
#### :bootstrap
|
66
|
+
Optional filename, the contents of which will get combined with :cloud_config_yaml to form the cloud-init payload (using mime encoding, supported types are #include, ) with some variable substation (chef server ip, environment, validation.pem, roles) See lib/chef-client-bootstrap-excl-validation-pem.sh as an example.
|
67
|
+
|
68
|
+
:cloud_config_yaml & :bootstrap files can be of the following type:
|
69
|
+
|
70
|
+
mime-type | first line
|
71
|
+
-------------------|-----------
|
72
|
+
text/x-include-url | #include
|
73
|
+
text/x-shellscript | #!
|
74
|
+
text/cloud-config | #cloud-config
|
75
|
+
text/upstart-job | #upstart-job
|
76
|
+
text/part-handler | #part-handler
|
77
|
+
text/cloud-boothook | #cloud-boothook
|
78
|
+
|
79
|
+
#### :data_dir
|
80
|
+
data_dir is a hook into the optional :cloud_config_yaml template (lib/cloud-config-w-ephemeral.yaml), which formats & mounts ephemeral0 early in the boot process, allowing it to be used during the rest of the cloud-init. ephemeral0 is mounted as /mnt & then bind mounted to #{data_dir}
|
81
|
+
|
82
|
+
#### :floating_ips
|
83
|
+
This can be an array of strings, such that node X will be assigned :floating_ips[X-1] via "nova add-floating-ip". If :floating_ips[X-1].nil?, then no floating ip will be attached. (the floating ip must already be assigned to a pool in your account)
|
84
|
+
|
85
|
+
#### :post_install_script, :post_install_args & :post_install_cwd
|
86
|
+
These are used to construct a command to execute, which is executed locally where you executed stack-kicker. :post_install_args can contain %PUBLIC_IP%, which will be replaced by the public IP of the just created node. :post_install_script scripts are executed as soon the the instance returns a status='ACTIVE'. They can be used delay the creation of further nodes of the same role (for example, when creating a rabbitmq cluster, you need to wait for the rabbitmq process to be running before creating the next member of the cluster, or when you are creating a chef-server, you need to wait for the packages to install & daemons to start before attempting to create Chef users & retrieve keys)
|
87
|
+
|
88
|
+
|
35
89
|
## Example workflows/models
|
36
90
|
stack-kicker was built with the following workflows in mind:
|
37
91
|
|
@@ -39,11 +93,11 @@ stack-kicker was built with the following workflows in mind:
|
|
39
93
|
This was the original requirement, a multi-role application stack build that started
|
40
94
|
with building a chef-server, uploading roles, environments, cookbooks & databags to it,
|
41
95
|
and then building the rest of the application-stack instances, using the freshly built chef-server
|
42
|
-
to drop the application on to the instances. In this setup we used vanilla images (Ubuntu 12.04 LTS,
|
96
|
+
to drop the application on to the instances. In this setup we used vanilla images (Ubuntu 12.04 LTS,
|
43
97
|
but you could use any image, either vanilla or pre-populated with your software).
|
44
98
|
|
45
99
|
Here's an example Stackfile for this:
|
46
|
-
|
100
|
+
|
47
101
|
module StackConfig
|
48
102
|
Stacks = {
|
49
103
|
'web-w-chef-server' => {
|
@@ -53,14 +107,14 @@ Here's an example Stackfile for this:
|
|
53
107
|
'PASSWORD' => ENV['OS_PASSWORD'],
|
54
108
|
'AUTH_URL' => ENV['OS_AUTH_URL'],
|
55
109
|
'TENANT_NAME' => ENV['OS_TENANT_NAME'],
|
56
|
-
|
110
|
+
|
57
111
|
# generic instance info
|
58
112
|
'flavor_id' => 103,
|
59
113
|
'image_id' => 75845,
|
60
114
|
:key_pair => 'ssh-keypair-name',
|
61
115
|
:key_public => '/path/to/id_rsa.pub',
|
62
116
|
:global_service_name => 'perconaconf',
|
63
|
-
|
117
|
+
|
64
118
|
# role details
|
65
119
|
:roles => {
|
66
120
|
# override the default cloud-init script & default bootstrap (which is a chef-client bootstrap)
|
@@ -70,13 +124,13 @@ Here's an example Stackfile for this:
|
|
70
124
|
# override the default cloud-config with a chef-server template
|
71
125
|
:cloud_config_yaml => 'chef-cloud-config.yaml',
|
72
126
|
# skip the default chef-client bootstrap
|
73
|
-
:bootstrap => '',
|
127
|
+
:bootstrap => '',
|
74
128
|
# wait for the chef server to come up & download pem files & generate client account
|
75
129
|
:post_install_script => 'bootstrap/chef-post-install.sh',
|
76
|
-
# our post install script dumps out .pem files in the CWD
|
77
|
-
:post_install_cwd => '.chef',
|
130
|
+
# our post install script dumps out .pem files in the CWD
|
131
|
+
:post_install_cwd => '.chef',
|
78
132
|
# The post-install script needs to know the public IP of the just built instance so that this station can access it
|
79
|
-
:post_install_args => '%PUBLIC_IP%'
|
133
|
+
:post_install_args => '%PUBLIC_IP%'
|
80
134
|
},
|
81
135
|
# much simpler role, just build 3 of these, chef-client will do the rest on boot
|
82
136
|
:web => { :count => 3 }
|
@@ -84,34 +138,34 @@ Here's an example Stackfile for this:
|
|
84
138
|
}
|
85
139
|
}
|
86
140
|
end
|
87
|
-
|
141
|
+
|
88
142
|
|
89
143
|
### simple roles
|
90
|
-
There is no requirement that stack-kicker do anything other than spin up your instances, your requirements
|
144
|
+
There is no requirement that stack-kicker do anything other than spin up your instances, your requirements
|
91
145
|
may be such that you just need a number of instances started with certain images, region & flavor requirements.
|
92
146
|
|
93
147
|
### masterless puppet
|
94
|
-
aws-kicker (a sister project) had an original requirement of starting a simple 2-tier web application in multiple
|
95
|
-
locations/environments (prod, stage, dev etc), to do this we configured the instances by bootrapping puppet,
|
148
|
+
aws-kicker (a sister project) had an original requirement of starting a simple 2-tier web application in multiple
|
149
|
+
locations/environments (prod, stage, dev etc), to do this we configured the instances by bootrapping puppet,
|
96
150
|
git clonig /etc/puppet and running "puppet apply", a simple pattern used in many places, this was all achievd with a
|
97
151
|
carefully crafted cloud-init template (incidentally, this also allowed for simple prototyping using vagrant to
|
98
152
|
provide local instances using the exact same '/etc/puppet' git repo.
|
99
153
|
|
100
154
|
### Other workflows
|
101
|
-
These are only the workflows I've used, there is no reason a puppet master couldn't be built & used, or
|
155
|
+
These are only the workflows I've used, there is no reason a puppet master couldn't be built & used, or
|
102
156
|
hosted/external puppet & chef servers. (pull requests accepted etc, including salt, ansible, cfengine etc..)
|
103
157
|
|
104
158
|
## Installation
|
105
159
|
|
106
160
|
$ gem install stack-kicker
|
107
|
-
|
161
|
+
|
108
162
|
## Requirements
|
109
163
|
In addition to the the ruby dependencies which gem will install for you, access to python-novaclient is currently required to attach floating-ips to instances.
|
110
164
|
|
111
165
|
## Usage
|
112
166
|
|
113
167
|
Usage: stack-kicker [options] task
|
114
|
-
|
168
|
+
|
115
169
|
Options:
|
116
170
|
-h, --help Show command line help
|
117
171
|
--stackfile Stackfile Specify an alternative Stackfile
|
@@ -122,7 +176,7 @@ In addition to the the ruby dependencies which gem will install for you, access
|
|
122
176
|
--log-level LEVEL Set the logging level
|
123
177
|
(debug|info|warn|error|fatal)
|
124
178
|
(Default: info)
|
125
|
-
|
179
|
+
|
126
180
|
Arguments:
|
127
181
|
|
128
182
|
task
|
@@ -130,13 +184,13 @@ In addition to the the ruby dependencies which gem will install for you, access
|
|
130
184
|
|
131
185
|
## TODO
|
132
186
|
|
133
|
-
1. Clean up provider logic
|
187
|
+
1. Clean/Add up provider logic
|
134
188
|
2. Remove dependency on python-novaclient for floating-ip attach
|
135
189
|
3. Remove dependency on a full chef gem install
|
136
190
|
4. Better docs & examples
|
137
191
|
5. Support for AWS EC2 (from aws-kicker)
|
138
192
|
5. Support for DNS Updates on instance creation (from aws-kicker)
|
139
|
-
|
193
|
+
|
140
194
|
## Contributing
|
141
195
|
|
142
196
|
1. Fork it
|
data/doc/examples/.rvmrc
ADDED
@@ -0,0 +1,34 @@
|
|
1
|
+
#!/usr/bin/env bash
|
2
|
+
|
3
|
+
# This is an RVM Project .rvmrc file, used to automatically load the ruby
|
4
|
+
# development environment upon cd'ing into the directory
|
5
|
+
|
6
|
+
# First we specify our desired <ruby>[@<gemset>], the @gemset name is optional,
|
7
|
+
# Only full ruby name is supported here, for short names use:
|
8
|
+
# echo "rvm use 1.9.3" > .rvmrc
|
9
|
+
environment_id="ruby-1.9.3-p327@stack-kicker-examples"
|
10
|
+
|
11
|
+
# Uncomment the following lines if you want to verify rvm version per project
|
12
|
+
# rvmrc_rvm_version="1.17.3 (stable)" # 1.10.1 seams as a safe start
|
13
|
+
# eval "$(echo ${rvm_version}.${rvmrc_rvm_version} | awk -F. '{print "[[ "$1*65536+$2*256+$3" -ge "$4*65536+$5*256+$6" ]]"}' )" || {
|
14
|
+
# echo "This .rvmrc file requires at least RVM ${rvmrc_rvm_version}, aborting loading."
|
15
|
+
# return 1
|
16
|
+
# }
|
17
|
+
|
18
|
+
# First we attempt to load the desired environment directly from the environment
|
19
|
+
# file. This is very fast and efficient compared to running through the entire
|
20
|
+
# CLI and selector. If you want feedback on which environment was used then
|
21
|
+
# insert the word 'use' after --create as this triggers verbose mode.
|
22
|
+
if [[ -d "${rvm_path:-$HOME/.rvm}/environments"
|
23
|
+
&& -s "${rvm_path:-$HOME/.rvm}/environments/$environment_id" ]]
|
24
|
+
then
|
25
|
+
\. "${rvm_path:-$HOME/.rvm}/environments/$environment_id"
|
26
|
+
[[ -s "${rvm_path:-$HOME/.rvm}/hooks/after_use" ]] &&
|
27
|
+
\. "${rvm_path:-$HOME/.rvm}/hooks/after_use" || true
|
28
|
+
else
|
29
|
+
# If the environment file has not yet been created, use the RVM CLI to select.
|
30
|
+
rvm --create "$environment_id" || {
|
31
|
+
echo "Failed to create RVM environment '${environment_id}'."
|
32
|
+
return 1
|
33
|
+
}
|
34
|
+
fi
|
@@ -0,0 +1,40 @@
|
|
1
|
+
module StackConfig
|
2
|
+
Stacks = Hash.new
|
3
|
+
|
4
|
+
Stacks['apache-cloud-init'] = {
|
5
|
+
# (we can access environment variable via ENV['foo'] instead of hard coding u/p here)
|
6
|
+
'REGION' => ENV['OS_REGION_NAME'],
|
7
|
+
'USERNAME' => ENV['OS_USERNAME'],
|
8
|
+
'PASSWORD' => ENV['OS_PASSWORD'],
|
9
|
+
'AUTH_URL' => ENV['OS_AUTH_URL'],
|
10
|
+
'TENANT_NAME' => ENV['OS_TENANT_NAME'],
|
11
|
+
|
12
|
+
# generic instance info
|
13
|
+
'flavor_id' => 103,
|
14
|
+
'image_id' => 75845, # Ubuntu Precise 12.04 LTS Server 64-bit
|
15
|
+
# per-az image_id's
|
16
|
+
'az-2.region-a.geo-1' => { 'image_id' => 67074 },
|
17
|
+
|
18
|
+
# provisioning info
|
19
|
+
:key_pair => 'dnsaas-dev@hp.com',
|
20
|
+
:key_public => '../credentials/ssh-keys/dnsaas-dev@hp.com.pub',
|
21
|
+
|
22
|
+
:name_template => '%s-%s-%s%04d', # service-site-role0001
|
23
|
+
:global_service_name => 'webci',
|
24
|
+
:site_template => '%s',
|
25
|
+
|
26
|
+
# role specification
|
27
|
+
# role names & chef roles should match
|
28
|
+
:roles => {
|
29
|
+
:web => { :count => 1,
|
30
|
+
# use the default security group
|
31
|
+
:security_group => 'default',
|
32
|
+
# don't use chef as the second-stage provisioner
|
33
|
+
:skip_chef_prereg => true,
|
34
|
+
:bootstrap => 'cloud-init.sh',
|
35
|
+
# use the yaml template that supports configuring the ephemeral space early in the boot process
|
36
|
+
:cloud_config_yaml => 'cloud-init.yaml',
|
37
|
+
}
|
38
|
+
}
|
39
|
+
}
|
40
|
+
end
|
@@ -0,0 +1,8 @@
|
|
1
|
+
#!/bin/bash
|
2
|
+
|
3
|
+
cat > /var/www/index.html <<HEREDOC
|
4
|
+
<html><body><h1>Stack-Kicker was 'ere!</h1>
|
5
|
+
<p>This file is dropped in place by a shell script passed to the host via cloud-init</p>
|
6
|
+
<p>The web server software is running but no content has been added, yet.</p>
|
7
|
+
</body></html>
|
8
|
+
HEREDOC
|
@@ -0,0 +1,28 @@
|
|
1
|
+
#cloud-config
|
2
|
+
|
3
|
+
output:
|
4
|
+
all: ">> /var/log/cloud-init.log"
|
5
|
+
|
6
|
+
# use the HPCS Ubuntu Mirror for security-updates too
|
7
|
+
bootcmd:
|
8
|
+
- echo 127.0.1.1 %HOSTNAME% >> /etc/hosts
|
9
|
+
- echo # force security.ubuntu.com to mirror.clouds.archive.ubuntu.com >> /etc/hosts
|
10
|
+
- echo 15.185.107.200 security.ubuntu.com >> /etc/hosts
|
11
|
+
|
12
|
+
# use the HPCS Ubuntu Mirrors
|
13
|
+
apt_mirror: http://nova.clouds.archive.ubuntu.com/ubuntu
|
14
|
+
|
15
|
+
# Run apt-get update
|
16
|
+
package_update: true
|
17
|
+
|
18
|
+
# Run apt-get update
|
19
|
+
package_upgrade: true
|
20
|
+
|
21
|
+
# Install some packages
|
22
|
+
packages:
|
23
|
+
- apache2
|
24
|
+
- php5
|
25
|
+
|
26
|
+
# Use `sudo -i` to simulate a login shell...
|
27
|
+
runcmd:
|
28
|
+
- sudo -i touch /var/log/cloud-init.complete
|
data/lib/stack-kicker/version.rb
CHANGED
data/lib/stack.rb
CHANGED
@@ -28,10 +28,10 @@ require 'tempfile'
|
|
28
28
|
|
29
29
|
#
|
30
30
|
# This really needs to be converted into a class....
|
31
|
-
#
|
31
|
+
#
|
32
32
|
module Stack
|
33
33
|
|
34
|
-
# Shadow the global constant Logger with Stack::Logger
|
34
|
+
# Shadow the global constant Logger with Stack::Logger
|
35
35
|
# (if you want access to the global constant, use ::Logger from inside the Stack module)
|
36
36
|
Logger = Logger.new(STDOUT)
|
37
37
|
Logger.level = ::Logger::INFO
|
@@ -60,7 +60,7 @@ module Stack
|
|
60
60
|
Logger.info { " #{name}" }
|
61
61
|
end
|
62
62
|
end
|
63
|
-
|
63
|
+
|
64
64
|
def Stack.show_stack(config)
|
65
65
|
# syntax_check is a light weight check that doesn't talk to OpenStalk
|
66
66
|
Stack.syntax_check(config)
|
@@ -76,7 +76,7 @@ module Stack
|
|
76
76
|
eval(config_raw)
|
77
77
|
|
78
78
|
# if there is only one stack defined in the Stackfile, load it:
|
79
|
-
if StackConfig::Stacks.count == 1 && stack_name.nil?
|
79
|
+
if StackConfig::Stacks.count == 1 && stack_name.nil?
|
80
80
|
stack_name = StackConfig::Stacks.keys[0]
|
81
81
|
Logger.info { "Defaulting to #{stack_name} as there is a single stack defined and no stack named" }
|
82
82
|
end
|
@@ -129,9 +129,9 @@ module Stack
|
|
129
129
|
|
130
130
|
# check that all the required config items are set
|
131
131
|
def Stack.syntax_check(config)
|
132
|
-
if config['REGION'].nil? || config['USERNAME'].nil? || config['PASSWORD'].nil? || config['AUTH_URL'].nil? || config['TENANT_NAME'].nil? &&
|
132
|
+
if config['REGION'].nil? || config['USERNAME'].nil? || config['PASSWORD'].nil? || config['AUTH_URL'].nil? || config['TENANT_NAME'].nil? &&
|
133
133
|
config['REGION'].empty? || config['USERNAME'].empty? || config['PASSWORD'].empty? || config['AUTH_URL'].empty? || config['TENANT_NAME'].empty?
|
134
|
-
Logger.error { "REGION, USERNAME, PASSWORD, AUTH_URL & TENANT_NAME must all be set" }
|
134
|
+
Logger.error { "REGION, USERNAME, PASSWORD, AUTH_URL & TENANT_NAME must all be set" }
|
135
135
|
exit
|
136
136
|
end
|
137
137
|
|
@@ -146,7 +146,7 @@ module Stack
|
|
146
146
|
if !File.directory?(dot_chef_abs)
|
147
147
|
Logger.warn "#{dot_chef_abs} doesn't exist"
|
148
148
|
end
|
149
|
-
|
149
|
+
|
150
150
|
# Check we have a #{dot_chef_abs}/.chef/knife.rb
|
151
151
|
knife_rb_abs = dot_chef_abs + '/knife.rb'
|
152
152
|
if File.exists?(knife_rb_abs)
|
@@ -175,7 +175,7 @@ module Stack
|
|
175
175
|
Logger.error "Couldn't find #{config[:key_pair]} key in the ssh-agent key list! Aborting!"
|
176
176
|
Logger.erroLogger.error "ssh_keys_loaded: #{ssh_keys_loaded}"
|
177
177
|
exit 2
|
178
|
-
end
|
178
|
+
end
|
179
179
|
end
|
180
180
|
|
181
181
|
# populate the config & then walk through the AZs verifying the config
|
@@ -199,7 +199,7 @@ module Stack
|
|
199
199
|
keypairs = os.keypairs()
|
200
200
|
if (keypairs[config[:key_pair]].nil? && keypairs[config[:key_pair].to_sym].nil?)
|
201
201
|
Logger.warn "#{config[:key_pair]} isn't available, uploading the key"
|
202
|
-
|
202
|
+
|
203
203
|
# upload the key
|
204
204
|
key = os.create_keypair({:name=> config[:key_pair], :public_key=> File.read(config[:key_public])})
|
205
205
|
Logger.warn "#{config[:key_pair]} fingerprint=#{key[:fingerprint]}"
|
@@ -216,10 +216,10 @@ module Stack
|
|
216
216
|
|
217
217
|
config[:roles].each do |role, role_details|
|
218
218
|
# is does the secgroup exist?
|
219
|
-
if sg_names.include?(
|
220
|
-
Logger.info "security group #{
|
219
|
+
if sg_names.include?(role_details[:security_group])
|
220
|
+
Logger.info "security group #{role_details[:security_group]} exists in #{az}"
|
221
221
|
else
|
222
|
-
Logger.error "security group #{
|
222
|
+
Logger.error "security group #{role_details[:security_group]} is missing in #{az}"
|
223
223
|
end
|
224
224
|
end
|
225
225
|
end
|
@@ -251,7 +251,7 @@ module Stack
|
|
251
251
|
|
252
252
|
client_key = dot_chef_abs + '/' + config[:name] + '-' + ENV['USER'] + '.pem'
|
253
253
|
validation_key = dot_chef_abs + '/' + config[:name] + '-' + 'validation.pem'
|
254
|
-
|
254
|
+
|
255
255
|
Logger.debug "stackhome: #{config[:stackhome]}"
|
256
256
|
Logger.debug "Current user client key: #{client_key}"
|
257
257
|
Logger.debug "New Host Validation key: #{validation_key}"
|
@@ -290,11 +290,11 @@ cookbook_path [ '<%=config[:stackhome]%>/cookbooks' ]
|
|
290
290
|
Logger.debug role_details[:azs]
|
291
291
|
|
292
292
|
site = sprintf(config[:site_template], role_details[:azs][position-1].split('.')[0].sub(/-/, ''))
|
293
|
-
|
293
|
+
|
294
294
|
# generate the hostname
|
295
295
|
hostname = sprintf(config[:name_template], config[:global_service_name], site, role, position)
|
296
296
|
|
297
|
-
hostname
|
297
|
+
hostname
|
298
298
|
end
|
299
299
|
|
300
300
|
def Stack.generate_server_names(config)
|
@@ -305,7 +305,7 @@ cookbook_path [ '<%=config[:stackhome]%>/cookbooks' ]
|
|
305
305
|
|
306
306
|
def Stack.populate_config(config)
|
307
307
|
# config[:role_details] contains built out role details with defaults filled in from stack defaults
|
308
|
-
# config[:node_details] contains node details built out from role_details
|
308
|
+
# config[:node_details] contains node details built out from role_details
|
309
309
|
|
310
310
|
# set some sensible defaults to the stack-wide defaults if they haven't been set in the Stackfile.
|
311
311
|
if config[:provisioner].nil?
|
@@ -330,7 +330,7 @@ cookbook_path [ '<%=config[:stackhome]%>/cookbooks' ]
|
|
330
330
|
|
331
331
|
if config[:name_template].nil?
|
332
332
|
Logger.warn { "Defaulting to '%s-%s-%s%04d' for config[:name_template]" }
|
333
|
-
config[:name_template] = '%s-%s-%s%04d'
|
333
|
+
config[:name_template] = '%s-%s-%s%04d'
|
334
334
|
end
|
335
335
|
|
336
336
|
if config[:site_template].nil?
|
@@ -343,24 +343,24 @@ cookbook_path [ '<%=config[:stackhome]%>/cookbooks' ]
|
|
343
343
|
config[:site_template] = 'UNKNOWN'
|
344
344
|
end
|
345
345
|
|
346
|
-
|
346
|
+
|
347
347
|
if config[:node_details].nil?
|
348
348
|
Logger.debug { "Initializing config[:node_details] and config[:azs]" }
|
349
349
|
config[:node_details] = Hash.new
|
350
350
|
config[:azs] = Array.new
|
351
351
|
|
352
|
-
config[:roles].each do |role,role_details|
|
352
|
+
config[:roles].each do |role,role_details|
|
353
353
|
Logger.debug { "Setting defaults for #{role}" }
|
354
|
-
|
354
|
+
|
355
355
|
# default to 1 node of this role if :count isn't set
|
356
356
|
if role_details[:count].nil?
|
357
357
|
role_details[:count] = 1
|
358
358
|
end
|
359
|
-
|
359
|
+
|
360
360
|
if (role_details[:data_dir].nil?)
|
361
361
|
role_details[:data_dir] = '/dummy'
|
362
362
|
end
|
363
|
-
|
363
|
+
|
364
364
|
# Has the cloud_config_yaml been overridden?
|
365
365
|
if (role_details[:cloud_config_yaml])
|
366
366
|
role_details[:cloud_config_yaml] = Stack.find_file(config, role_details[:cloud_config_yaml])
|
@@ -390,24 +390,28 @@ cookbook_path [ '<%=config[:stackhome]%>/cookbooks' ]
|
|
390
390
|
role_details[:post_install_cwd] = '/.'
|
391
391
|
end
|
392
392
|
|
393
|
+
if role_details[:post_install_args].nil?
|
394
|
+
role_details[:post_install_args] = ''
|
395
|
+
end
|
396
|
+
|
393
397
|
(1..role_details[:count]).each do |p|
|
394
398
|
Logger.debug { "Populating the config[:role_details][:azs] array with AZ" }
|
395
399
|
role_details[:azs] = Array.new if role_details[:azs].nil?
|
396
|
-
|
400
|
+
|
397
401
|
# is there an az set for this node?
|
398
402
|
if role_details[:azs][p-1].nil?
|
399
|
-
# inherit the global az
|
403
|
+
# inherit the global az
|
400
404
|
Logger.debug { "Inheriting the AZ for #{role} (#{config['REGION']})" }
|
401
405
|
role_details[:azs][p-1] = config['REGION']
|
402
406
|
end
|
403
|
-
|
407
|
+
|
404
408
|
# add this AZ to the AZ list, we'll dedupe later
|
405
409
|
config[:azs] << role_details[:azs][p-1]
|
406
|
-
|
410
|
+
|
407
411
|
hostname = Stack.generate_hostname(config, role, p)
|
408
412
|
Logger.debug { "Setting node_details for #{hostname}, using element #{p}-1 from #{role_details[:azs]}" }
|
409
413
|
config[:node_details][hostname] = { :az => role_details[:azs][p-1], :region => role_details[:azs][p-1], :role => role }
|
410
|
-
end
|
414
|
+
end
|
411
415
|
end
|
412
416
|
end
|
413
417
|
config[:azs].uniq!
|
@@ -429,18 +433,18 @@ cookbook_path [ '<%=config[:stackhome]%>/cookbooks' ]
|
|
429
433
|
if config[:all_instances].nil? || refresh
|
430
434
|
# we need to get the server list for each AZ mentioned in the config[:roles][:role][:azs], this is populated by Stack.populate_config
|
431
435
|
Stack.populate_config(config)
|
432
|
-
|
436
|
+
|
433
437
|
# get the current list of servers from OpenStack & generate a hash, keyed on name
|
434
438
|
servers = Hash.new
|
435
439
|
config[:azs].each do |az|
|
436
440
|
os = Stack.connect(config, az)
|
437
|
-
os.servers.each do |server|
|
438
|
-
servers[server[:name]] = {
|
441
|
+
os.servers.each do |server|
|
442
|
+
servers[server[:name]] = {
|
439
443
|
:region => az,
|
440
|
-
:id => server[:id],
|
444
|
+
:id => server[:id],
|
441
445
|
:addresses => os.server(server[:id]).addresses
|
442
446
|
}
|
443
|
-
end
|
447
|
+
end
|
444
448
|
end
|
445
449
|
config[:all_instances] = servers
|
446
450
|
end
|
@@ -460,9 +464,9 @@ cookbook_path [ '<%=config[:stackhome]%>/cookbooks' ]
|
|
460
464
|
def Stack.add_instance(config, hostname, region, id, addresses)
|
461
465
|
config[:all_instances][hostname] = { :region => region, :id => id, :addresses => addresses}
|
462
466
|
end
|
463
|
-
|
467
|
+
|
464
468
|
def Stack.ssh(config, hostname = nil, user = ENV['USER'], command = nil)
|
465
|
-
# ssh to a host, or all hosts
|
469
|
+
# ssh to a host, or all hosts
|
466
470
|
|
467
471
|
# get all running instances
|
468
472
|
servers = Stack.get_our_instances(config)
|
@@ -523,10 +527,10 @@ cookbook_path [ '<%=config[:stackhome]%>/cookbooks' ]
|
|
523
527
|
# this also populates out unspecified defaults, like az
|
524
528
|
Stack.populate_config(config)
|
525
529
|
|
526
|
-
# get the list of nodes we consider 'ours', i.e. with hostnames that match
|
530
|
+
# get the list of nodes we consider 'ours', i.e. with hostnames that match
|
527
531
|
# those generated by this stack
|
528
532
|
ours = Stack.get_our_instances(config)
|
529
|
-
|
533
|
+
|
530
534
|
# do any of the list of servers in OpenStack match one of our hostnames?
|
531
535
|
ours.each do |node, node_details|
|
532
536
|
Logger.info "Deleting #{node}"
|
@@ -535,9 +539,9 @@ cookbook_path [ '<%=config[:stackhome]%>/cookbooks' ]
|
|
535
539
|
d.delete!
|
536
540
|
end
|
537
541
|
end
|
538
|
-
|
542
|
+
|
539
543
|
def Stack.get_public_ip(config, hostname)
|
540
|
-
# get a public address from the instance
|
544
|
+
# get a public address from the instance
|
541
545
|
# (could be either the dynamic or one of our floating IPs
|
542
546
|
config[:all_instances][hostname][:addresses].each do |address|
|
543
547
|
if address.label == 'public'
|
@@ -547,7 +551,7 @@ cookbook_path [ '<%=config[:stackhome]%>/cookbooks' ]
|
|
547
551
|
end
|
548
552
|
|
549
553
|
def Stack.set_chef_server(config, chef_server)
|
550
|
-
# set the private & public URLs for the chef server,
|
554
|
+
# set the private & public URLs for the chef server,
|
551
555
|
# called either after we create the Chef Server, or skip over it
|
552
556
|
Logger.debug "Setting :chef_server_hostname, chef_server_private & chef_server_public details (using #{chef_server})"
|
553
557
|
|
@@ -575,18 +579,18 @@ cookbook_path [ '<%=config[:stackhome]%>/cookbooks' ]
|
|
575
579
|
# 2) generate the json to describe that to the "stackhelper secgroup-sync" tool
|
576
580
|
# 3) run "stackhelper secgroup-sync --some-file our-ips.json"
|
577
581
|
ours = Stack.get_our_instances(config)
|
578
|
-
|
582
|
+
|
579
583
|
secgroup_ips = Hash.new
|
580
584
|
# walk the list of hosts, dumping the IPs into role buckets
|
581
585
|
ours.each do |instance, instance_details|
|
582
586
|
secgroup_ips[instance_details[:role]] = Array.new if secgroup_ips[instance_details[:role]].nil?
|
583
587
|
|
584
588
|
#secgroup_ips[instance_details[:role]] << instance_details[:addresses].map { |address| address.address }
|
585
|
-
secgroup_ips[instance_details[:role]] << instance_details[:addresses].map do |address|
|
586
|
-
if (address.label == 'public')
|
587
|
-
address.address
|
588
|
-
else
|
589
|
-
next
|
589
|
+
secgroup_ips[instance_details[:role]] << instance_details[:addresses].map do |address|
|
590
|
+
if (address.label == 'public')
|
591
|
+
address.address
|
592
|
+
else
|
593
|
+
next
|
590
594
|
end
|
591
595
|
end
|
592
596
|
|
@@ -594,7 +598,7 @@ cookbook_path [ '<%=config[:stackhome]%>/cookbooks' ]
|
|
594
598
|
secgroup_ips[instance_details[:role]].flatten!
|
595
599
|
|
596
600
|
# delete any nil's that we collected due to skipping private ips
|
597
|
-
secgroup_ips[instance_details[:role]].delete_if {|x| x.nil? }
|
601
|
+
secgroup_ips[instance_details[:role]].delete_if {|x| x.nil? }
|
598
602
|
end
|
599
603
|
|
600
604
|
# dump the json to a temp file
|
@@ -618,20 +622,20 @@ cookbook_path [ '<%=config[:stackhome]%>/cookbooks' ]
|
|
618
622
|
# if we're passed a role, only deploy this role.
|
619
623
|
def Stack.deploy_all(config, role_to_deploy = nil)
|
620
624
|
Stack.validate(config)
|
621
|
-
|
625
|
+
|
622
626
|
# this also populates out unspecified defaults, like az
|
623
627
|
node_details = Stack.populate_config(config)
|
624
628
|
# get info about all instances running in our account & AZs
|
625
629
|
servers = Stack.get_all_instances(config)
|
626
630
|
|
627
631
|
# this is our main loop iterator, generates each host
|
628
|
-
config[:roles].each do |role,role_details|
|
632
|
+
config[:roles].each do |role,role_details|
|
629
633
|
Logger.debug { "Iterating over roles, this is #{role}, role_details = #{role_details}" }
|
630
634
|
|
631
635
|
(1..role_details[:count]).each do |p|
|
632
636
|
hostname = Stack.generate_hostname(config, role, p)
|
633
637
|
Logger.debug { "Iterating over nodes in #{role}, this is #{hostname}" }
|
634
|
-
|
638
|
+
|
635
639
|
# configure the global :chef_server details if this the chef server
|
636
640
|
if role_details[:chef_server]
|
637
641
|
Stack.set_chef_server(config, hostname)
|
@@ -642,7 +646,7 @@ cookbook_path [ '<%=config[:stackhome]%>/cookbooks' ]
|
|
642
646
|
Logger.info { "#{hostname} already exists, skipping.." }
|
643
647
|
next
|
644
648
|
end
|
645
|
-
|
649
|
+
|
646
650
|
Logger.debug { "Deploying #{role}, role_to_deploy = #{role_to_deploy}" }
|
647
651
|
if ((role_to_deploy.nil?) || (role_to_deploy.to_s == role.to_s))
|
648
652
|
if (role_details[:skip_chef_prereg] == true || role_details[:chef_server])
|
@@ -654,9 +658,9 @@ cookbook_path [ '<%=config[:stackhome]%>/cookbooks' ]
|
|
654
658
|
knife_client_list.sub!(/\s/,'')
|
655
659
|
if knife_client_list.length() > 0
|
656
660
|
# we should delete the client to make way for this new machine
|
657
|
-
Logger.info `knife client delete --yes #{hostname}`
|
661
|
+
Logger.info `knife client delete --yes #{hostname}`
|
658
662
|
end
|
659
|
-
|
663
|
+
|
660
664
|
# knife node create -d --environment $CHEF_ENVIRONMENT $SERVER_NAME
|
661
665
|
# knife node run_list add -d --environment $CHEF_ENVIRONMENT $SERVER_NAME "role[${ROLE}]"
|
662
666
|
# this relies on .chef matching the stacks config (TODO: poke the Chef API directly?)
|
@@ -680,40 +684,47 @@ cookbook_path [ '<%=config[:stackhome]%>/cookbooks' ]
|
|
680
684
|
multipart_cmd = "#{libdir}/write-mime-multipart #{role_details[:bootstrap]} #{role_details[:cloud_config_yaml]}"
|
681
685
|
Logger.debug { "multipart_cmd = #{multipart_cmd}" }
|
682
686
|
multipart = `#{multipart_cmd}`
|
687
|
+
Logger.debug { "multipart = #{multipart}" }
|
683
688
|
# 2) replace the tokens (CHEF_SERVER, CHEF_ENVIRONMENT, SERVER_NAME, ROLE)
|
689
|
+
Logger.debug { "Replacing %HOSTNAME% with #{hostname} in multipart" }
|
684
690
|
multipart.gsub!(%q!%HOSTNAME%!, hostname)
|
685
691
|
|
686
|
-
|
687
|
-
|
688
|
-
# if this host is in the same region/az, use the private URL, if not, use the public url
|
689
|
-
if (config[:node_details][hostname][:region] == config[:node_details][config[:chef_server_hostname]][:region]) && !config[:chef_server_private].nil?
|
690
|
-
multipart.gsub!(%q!%CHEF_SERVER%!, config[:chef_server_private])
|
691
|
-
elsif !config[:chef_server_public].nil?
|
692
|
-
multipart.gsub!(%q!%CHEF_SERVER%!, config[:chef_server_public])
|
692
|
+
if config[:chef_server_hostname].nil?
|
693
|
+
Logger.info "config[:chef_server_hostname] is nil, skipping chef server substitution"
|
693
694
|
else
|
694
|
-
Logger.
|
695
|
-
|
696
|
-
|
697
|
-
|
698
|
-
|
699
|
-
|
700
|
-
|
695
|
+
Logger.info "Chef server is #{config[:chef_server_hostname]}, which is in #{config[:node_details][config[:chef_server_hostname]][:region]}"
|
696
|
+
Logger.info "#{hostname}'s region is #{config[:node_details][hostname][:region]}"
|
697
|
+
# if this host is in the same region/az, use the private URL, if not, use the public url
|
698
|
+
if (config[:node_details][hostname][:region] == config[:node_details][config[:chef_server_hostname]][:region]) && !config[:chef_server_private].nil?
|
699
|
+
multipart.gsub!(%q!%CHEF_SERVER%!, config[:chef_server_private])
|
700
|
+
elsif !config[:chef_server_public].nil?
|
701
|
+
multipart.gsub!(%q!%CHEF_SERVER%!, config[:chef_server_public])
|
702
|
+
else
|
703
|
+
Logger.warn { "Not setting the chef url for #{hostname} as neither chef_server_private or chef_server_public are valid yet" }
|
704
|
+
end
|
705
|
+
multipart.gsub!(%q!%CHEF_ENVIRONMENT%!, config[:chef_environment])
|
706
|
+
if File.exists?(config[:chef_validation_pem])
|
707
|
+
multipart.gsub!(%q!%CHEF_VALIDATION_PEM%!, File.read(config[:chef_validation_pem]))
|
708
|
+
else
|
709
|
+
Logger.warn "Skipping #{config[:chef_validation_pem]} substitution in user-data"
|
710
|
+
end
|
701
711
|
end
|
712
|
+
|
702
713
|
multipart.gsub!(%q!%SERVER_NAME%!, hostname)
|
703
714
|
multipart.gsub!(%q!%ROLE%!, role.to_s)
|
704
715
|
multipart.gsub!(%q!%DATA_DIR%!, role_details[:data_dir])
|
705
716
|
|
706
717
|
Logger.info "Creating #{hostname} in #{node_details[hostname][:az]} with role #{role}"
|
707
718
|
|
708
|
-
# this will get put in /meta.js
|
719
|
+
# this will get put in /meta.js
|
709
720
|
metadata = { 'region' => node_details[hostname][:az], 'chef_role' => role }
|
710
721
|
|
711
722
|
os = Stack.connect(config, node_details[hostname][:az])
|
712
|
-
newserver = os.create_server(:name => hostname,
|
713
|
-
:imageRef => config[node_details[hostname][:az]]['image_id'],
|
723
|
+
newserver = os.create_server(:name => hostname,
|
724
|
+
:imageRef => config[node_details[hostname][:az]]['image_id'],
|
714
725
|
:flavorRef => config['flavor_id'],
|
715
726
|
:security_groups=>[role_details[:security_group]],
|
716
|
-
:user_data => Base64.encode64(multipart),
|
727
|
+
:user_data => Base64.encode64(multipart),
|
717
728
|
:metadata => metadata,
|
718
729
|
:key_name => config[:key_pair])
|
719
730
|
|
@@ -742,9 +753,9 @@ cookbook_path [ '<%=config[:stackhome]%>/cookbooks' ]
|
|
742
753
|
Logger.info "Attaching #{floating_ip} to #{hostname}\n via 'nova add-floating-ip'"
|
743
754
|
# nova --os-region-name $REGION add-floating-ip $SERVER_NAME $FLOATING_IP
|
744
755
|
floating_ip_add = `nova --os-region-name #{node_details[hostname][:az]} add-floating-ip #{hostname} #{floating_ip}`
|
745
|
-
Logger.info floating_ip_add
|
746
|
-
end
|
747
|
-
|
756
|
+
Logger.info floating_ip_add
|
757
|
+
end
|
758
|
+
|
748
759
|
# refresh the secgroups ASAP
|
749
760
|
Stack.secgroup_sync(config)
|
750
761
|
|
@@ -754,7 +765,7 @@ cookbook_path [ '<%=config[:stackhome]%>/cookbooks' ]
|
|
754
765
|
# convert when we got passed to an absolute path
|
755
766
|
post_install_script_abs = File.realpath(config[:stackhome] + '/' + role_details[:post_install_script])
|
756
767
|
post_install_cwd_abs = File.realpath(config[:stackhome] + '/' + role_details[:post_install_cwd])
|
757
|
-
|
768
|
+
|
758
769
|
# replace any tokens in the argument
|
759
770
|
public_ip = Stack.get_public_ip(config, hostname)
|
760
771
|
role_details[:post_install_args].sub!(%q!%PUBLIC_IP%!, public_ip)
|
@@ -766,7 +777,7 @@ cookbook_path [ '<%=config[:stackhome]%>/cookbooks' ]
|
|
766
777
|
else
|
767
778
|
Logger.info "Skipped role #{role}"
|
768
779
|
end
|
769
|
-
end
|
780
|
+
end
|
770
781
|
end
|
771
782
|
end
|
772
783
|
|
@@ -775,7 +786,7 @@ cookbook_path [ '<%=config[:stackhome]%>/cookbooks' ]
|
|
775
786
|
# 1) cwd
|
776
787
|
# 2) stackhome
|
777
788
|
# 3) gemhome/lib
|
778
|
-
dirs = [ './' ]
|
789
|
+
dirs = [ './' ]
|
779
790
|
dirs.push(config[:stackhome])
|
780
791
|
dirs.push(@@gemhome + '/lib')
|
781
792
|
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: stack-kicker
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0.
|
4
|
+
version: 0.0.11
|
5
5
|
prerelease:
|
6
6
|
platform: ruby
|
7
7
|
authors:
|
@@ -9,7 +9,7 @@ authors:
|
|
9
9
|
autorequire:
|
10
10
|
bindir: bin
|
11
11
|
cert_chain: []
|
12
|
-
date: 2013-
|
12
|
+
date: 2013-05-14 00:00:00.000000000 Z
|
13
13
|
dependencies:
|
14
14
|
- !ruby/object:Gem::Dependency
|
15
15
|
name: rdoc
|
@@ -133,13 +133,19 @@ extra_rdoc_files: []
|
|
133
133
|
files:
|
134
134
|
- .gitignore
|
135
135
|
- .rvmrc
|
136
|
+
- CHANGELOG.md
|
136
137
|
- Gemfile
|
137
138
|
- LICENSE.txt
|
138
139
|
- README.md
|
139
140
|
- README.rdoc
|
140
141
|
- Rakefile
|
141
142
|
- bin/stack-kicker
|
142
|
-
- doc/examples
|
143
|
+
- doc/examples/.rvmrc
|
144
|
+
- doc/examples/Gemfile
|
145
|
+
- doc/examples/apache-via-chef-server/Stackfile
|
146
|
+
- doc/examples/apache-via-cloud-init/Stackfile
|
147
|
+
- doc/examples/apache-via-cloud-init/cloud-init.sh
|
148
|
+
- doc/examples/apache-via-cloud-init/cloud-init.yaml
|
143
149
|
- features/stack-kicker.feature
|
144
150
|
- features/step_definitions/stack-kicker_steps.rb
|
145
151
|
- features/support/env.rb
|
File without changes
|